├── 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 |
8 |
{children}
9 |
10 | ); 11 | } 12 | -------------------------------------------------------------------------------- /next.config.ts: -------------------------------------------------------------------------------- 1 | import type { NextConfig } from "next"; 2 | 3 | const nextConfig: NextConfig = { 4 | images: { 5 | remotePatterns: [ 6 | { 7 | protocol: "https", 8 | hostname: "avatars.githubusercontent.com", 9 | }, 10 | ], 11 | }, 12 | }; 13 | 14 | export default nextConfig; 15 | -------------------------------------------------------------------------------- /src/components/theme/provider.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import { ThemeProvider as NextThemesProvider } from "next-themes" 5 | 6 | export function ThemeProvider({ 7 | children, 8 | ...props 9 | }: React.ComponentProps) { 10 | return {children} 11 | } 12 | -------------------------------------------------------------------------------- /src/components/providers/index.tsx: -------------------------------------------------------------------------------- 1 | import { ThemeProvider } from "@/components/theme/provider"; 2 | 3 | export default function RootProviders({ children }: { children: React.ReactNode }) { 4 | return ( 5 | 11 | {children} 12 | 13 | ); 14 | } -------------------------------------------------------------------------------- /src/hooks/use-debounce.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react' 2 | 3 | export function useDebounce(value: T, delay: number): T { 4 | const [debouncedValue, setDebouncedValue] = useState(value) 5 | 6 | useEffect(() => { 7 | const timer = setTimeout(() => { 8 | setDebouncedValue(value) 9 | }, delay) 10 | 11 | return () => { 12 | clearTimeout(timer) 13 | } 14 | }, [value, delay]) 15 | 16 | return debouncedValue 17 | } -------------------------------------------------------------------------------- /src/types/index.d.ts: -------------------------------------------------------------------------------- 1 | export type SiteConfig = { 2 | name: string; 3 | title: string; 4 | description: string; 5 | origin: string; 6 | og: string; 7 | keywords: string[]; 8 | creator: { 9 | name: string; 10 | url: string; 11 | } 12 | socials: { 13 | github: string; 14 | x: string; 15 | } 16 | } 17 | 18 | export type Theme = "light" | "dark"; 19 | 20 | export type Blog = { 21 | id: number; 22 | title: string; 23 | description: string; 24 | author: string; 25 | publishedAt: string; 26 | } -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.ts", 8 | "css": "src/app/globals.css", 9 | "baseColor": "zinc", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | }, 20 | "iconLibrary": "lucide" 21 | } -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import { dirname } from "path"; 2 | import { fileURLToPath } from "url"; 3 | import { FlatCompat } from "@eslint/eslintrc"; 4 | 5 | const __filename = fileURLToPath(import.meta.url); 6 | const __dirname = dirname(__filename); 7 | 8 | const compat = new FlatCompat({ 9 | baseDirectory: __dirname, 10 | }); 11 | 12 | const eslintConfig = [ 13 | ...compat.extends("next/core-web-vitals", "next/typescript"), 14 | { 15 | rules: { 16 | "@typescript-eslint/ban-ts-comment": 0, 17 | }, 18 | }, 19 | ]; 20 | 21 | export default eslintConfig; 22 | -------------------------------------------------------------------------------- /src/components/mdx/mdx-content-renderer.tsx: -------------------------------------------------------------------------------- 1 | import * as runtime from 'react/jsx-runtime' 2 | import { mdxComponents } from './components' 3 | import 'katex/dist/katex.min.css'; 4 | 5 | const useMDXComponent = (code: string) => { 6 | const fn = new Function(code) 7 | return fn({ ...runtime }).default 8 | } 9 | 10 | interface MDXProps { 11 | code: string 12 | components?: Record 13 | } 14 | 15 | export const MDXContentRenderer = ({ code, components }: MDXProps) => { 16 | const Component = useMDXComponent(code) 17 | return 18 | } -------------------------------------------------------------------------------- /src/hooks/use-mobile.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | const MOBILE_BREAKPOINT = 768 4 | 5 | export function useIsMobile() { 6 | const [isMobile, setIsMobile] = React.useState(undefined) 7 | 8 | React.useEffect(() => { 9 | const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`) 10 | const onChange = () => { 11 | setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) 12 | } 13 | mql.addEventListener("change", onChange) 14 | setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) 15 | return () => mql.removeEventListener("change", onChange) 16 | }, []) 17 | 18 | return !!isMobile 19 | } 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.* 7 | .yarn/* 8 | !.yarn/patches 9 | !.yarn/plugins 10 | !.yarn/releases 11 | !.yarn/versions 12 | 13 | # testing 14 | /coverage 15 | 16 | # next.js 17 | /.next/ 18 | /out/ 19 | 20 | # production 21 | /build 22 | 23 | # misc 24 | .DS_Store 25 | *.pem 26 | 27 | # debug 28 | npm-debug.log* 29 | yarn-debug.log* 30 | yarn-error.log* 31 | .pnpm-debug.log* 32 | 33 | # env files (can opt-in for committing if needed) 34 | .env* 35 | 36 | # vercel 37 | .vercel 38 | 39 | # typescript 40 | *.tsbuildinfo 41 | next-env.d.ts 42 | 43 | .velite -------------------------------------------------------------------------------- /src/components/mdx/callout.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils"; 2 | 3 | interface CalloutProps { 4 | icon?: string; 5 | children?: React.ReactNode; 6 | type?: "default" | "warning" | "danger"; 7 | } 8 | 9 | export function Callout({ 10 | children, 11 | icon, 12 | type = "default", 13 | ...props 14 | }: CalloutProps) { 15 | return ( 16 |
23 | {icon && {icon}} 24 |
{children}
25 |
26 | ); 27 | } -------------------------------------------------------------------------------- /src/components/mdx/codeblock.tsx: -------------------------------------------------------------------------------- 1 | import { CopyButton } from "@/components/copy-button"; 2 | import { cn } from "@/lib/utils"; 3 | 4 | export const CodeBlock = ({ 5 | children, 6 | className, 7 | ...props 8 | }: React.HTMLAttributes) => { 9 | return ( 10 |
11 |
12 | {children} 13 |
14 |
18 |         {children}
19 |       
20 |
21 | ); 22 | }; 23 | -------------------------------------------------------------------------------- /src/config/site.config.ts: -------------------------------------------------------------------------------- 1 | import { SiteConfig } from "@/types"; 2 | 3 | export const siteConfig: SiteConfig = { 4 | name: "Async Select", 5 | title: "Async Select built with React & shadcn/ui", 6 | description: "Generic async select for your next project (pun intended)", 7 | origin: "https://async.rdsx.dev", 8 | keywords: [ 9 | "Next.js 15", 10 | "React", 11 | "shadcn/ui", 12 | "Async Select", 13 | "Generic Selector", 14 | "Async Select", 15 | "Generic Select", 16 | ], 17 | og: "https://async.rdsx.dev/og.png", 18 | creator: { 19 | name: "rds_agi", 20 | url: "https://rdsx.dev", 21 | }, 22 | socials: { 23 | github: "https://github.com/rudrodip/asyncr", 24 | x: "https://x.com/rds_agi", 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/components/docs.tsx: -------------------------------------------------------------------------------- 1 | import { snippets } from "#site/content"; 2 | import { MDXContentRenderer } from "./mdx/mdx-content-renderer"; 3 | import { DashboardTableOfContents } from "./mdx/toc"; 4 | 5 | export const Docs = () => { 6 | const filteredSnippets = snippets.filter(snippet => snippet.slugAsParams === "component-api"); 7 | if (filteredSnippets.length === 0) { 8 | return null; 9 | } 10 | const snippet = filteredSnippets[0]; 11 | return ( 12 |
13 | 14 |
15 |
16 | 17 |
18 |
19 |
20 | ); 21 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2017", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "noEmit": true, 9 | "esModuleInterop": true, 10 | "module": "esnext", 11 | "moduleResolution": "bundler", 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "jsx": "preserve", 15 | "incremental": true, 16 | "plugins": [ 17 | { 18 | "name": "next" 19 | } 20 | ], 21 | "paths": { 22 | "@/*": ["./src/*"], 23 | "#site/content": ["./.velite"], 24 | "#velite": ["./velite.config.ts"] 25 | } 26 | }, 27 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 28 | "exclude": ["node_modules"] 29 | } 30 | -------------------------------------------------------------------------------- /src/components/ui/label.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as LabelPrimitive from "@radix-ui/react-label" 5 | import { cva, type VariantProps } from "class-variance-authority" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const labelVariants = cva( 10 | "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" 11 | ) 12 | 13 | const Label = React.forwardRef< 14 | React.ElementRef, 15 | React.ComponentPropsWithoutRef & 16 | VariantProps 17 | >(({ className, ...props }, ref) => ( 18 | 23 | )) 24 | Label.displayName = LabelPrimitive.Root.displayName 25 | 26 | export { Label } 27 | -------------------------------------------------------------------------------- /src/components/ui/input.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | const Input = React.forwardRef>( 6 | ({ className, type, ...props }, ref) => { 7 | return ( 8 | 17 | ) 18 | } 19 | ) 20 | Input.displayName = "Input" 21 | 22 | export { Input } 23 | -------------------------------------------------------------------------------- /src/app/not-found.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@/components/ui/button"; 2 | import { Card, CardHeader, CardTitle, CardContent, CardFooter } from "@/components/ui/card"; 3 | import Link from "next/link"; 4 | 5 | export default function NotFound() { 6 | return ( 7 |
8 | 9 | 10 | Page Not Found 11 | 12 | 13 |

14 | The page you are looking for doesn't exist or has been moved. 15 |

16 |
17 | 18 | 23 | 24 |
25 |
26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | # MIT License 2 | 3 | Copyright 2024-present rds_agi 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /src/app/error.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useEffect } from "react"; 4 | import { Button } from "@/components/ui/button"; 5 | import { Card, CardHeader, CardTitle, CardContent, CardFooter } from "@/components/ui/card"; 6 | 7 | export default function Error({ 8 | error, 9 | reset, 10 | }: { 11 | error: Error & { digest?: string }; 12 | reset: () => void; 13 | }) { 14 | useEffect(() => { 15 | console.error(error); 16 | }, [error]); 17 | 18 | return ( 19 |
20 | 21 | 22 | Something went wrong! 23 | 24 | 25 |

26 | An error occurred while loading this page. 27 |

28 |
29 | 30 | 38 | 39 |
40 |
41 | ); 42 | } 43 | -------------------------------------------------------------------------------- /src/components/theme/toggler.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { MoonIcon, SunIcon } from "lucide-react"; 4 | import { useTheme } from "next-themes"; 5 | import { Button } from "@/components/ui/button"; 6 | import { cn } from "@/lib/utils"; 7 | 8 | type ThemeTogglerProps = { 9 | className?: string; 10 | }; 11 | 12 | export default function ThemeToggler({ className }: ThemeTogglerProps) { 13 | const { resolvedTheme, setTheme } = useTheme(); 14 | 15 | const switchTheme = () => { 16 | setTheme(resolvedTheme === "dark" ? "light" : "dark"); 17 | }; 18 | 19 | const toggleTheme = () => { 20 | //@ts-ignore 21 | if (!document.startViewTransition) switchTheme(); 22 | 23 | //@ts-ignore 24 | document.startViewTransition(switchTheme); 25 | }; 26 | 27 | return ( 28 | 38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /src/app/actions.ts: -------------------------------------------------------------------------------- 1 | 'use server' 2 | 3 | import { faker } from '@faker-js/faker' 4 | 5 | export interface User { 6 | id: string 7 | name: string 8 | email: string 9 | role: string 10 | avatar: string 11 | } 12 | 13 | // Generate 2000 users once and reuse them 14 | const allUsers = Array.from({ length: 2000 }, () => ({ 15 | id: faker.string.uuid(), 16 | name: faker.person.fullName(), 17 | email: faker.internet.email(), 18 | role: faker.person.jobTitle(), 19 | avatar: faker.image.avatar() 20 | })) 21 | 22 | export async function searchUsers(query?: string): Promise { 23 | // Simulate server delay 24 | await new Promise(resolve => setTimeout(resolve, Math.random() * 1000 + 100)) 25 | 26 | // If there's a search query, filter the users 27 | if (query) { 28 | const lowercaseQuery = query.toLowerCase() 29 | return allUsers.filter(user => 30 | user.name.toLowerCase().includes(lowercaseQuery) 31 | ).slice(0, 10) // Return only first 10 matches 32 | } 33 | 34 | // Return first 10 users if no query 35 | return allUsers.slice(0, 10) 36 | } 37 | 38 | export async function searchAllUsers(): Promise { 39 | // Simulate server delay 40 | await new Promise(resolve => setTimeout(resolve, Math.random() * 1000 + 100)) 41 | 42 | return allUsers.slice(0, 200) 43 | } -------------------------------------------------------------------------------- /src/components/ui/popover.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as PopoverPrimitive from "@radix-ui/react-popover" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const Popover = PopoverPrimitive.Root 9 | 10 | const PopoverTrigger = PopoverPrimitive.Trigger 11 | 12 | const PopoverAnchor = PopoverPrimitive.Anchor 13 | 14 | const PopoverContent = React.forwardRef< 15 | React.ElementRef, 16 | React.ComponentPropsWithoutRef 17 | >(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( 18 | 19 | 29 | 30 | )) 31 | PopoverContent.displayName = PopoverPrimitive.Content.displayName 32 | 33 | export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor } 34 | -------------------------------------------------------------------------------- /src/components/copy-button.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import React, { useState } from "react"; 4 | import { CheckIcon, ClipboardIcon } from "@radix-ui/react-icons"; 5 | import { cn } from "@/lib/utils"; 6 | 7 | export const CopyButton = ({ children, className, iconClassName }: { children: React.ReactNode, className?: string, iconClassName?: string }) => { 8 | const [isCopied, setIsCopied] = useState(false); 9 | 10 | const copy = async () => { 11 | const sourceCode = extractSourceCode(children); 12 | await navigator.clipboard.writeText(sourceCode); 13 | setIsCopied(true); 14 | 15 | setTimeout(() => { 16 | setIsCopied(false); 17 | }, 1000); 18 | }; 19 | 20 | const extractSourceCode = (node: React.ReactNode): string => { 21 | if (typeof node === "string") { 22 | return node; 23 | } 24 | if (Array.isArray(node)) { 25 | return node.map(extractSourceCode).join(""); 26 | } 27 | if (React.isValidElement(node)) { 28 | const { props } = node; 29 | // @ts-ignore 30 | const children = React.Children.map(props.children, extractSourceCode)?.join(""); 31 | return `${children}`; 32 | } 33 | return ""; 34 | }; 35 | 36 | return ( 37 | 40 | ); 41 | }; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "next15-generic", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "velite && next dev --turbopack", 7 | "build": "velite && next build", 8 | "start": "velite && next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@faker-js/faker": "^9.3.0", 13 | "@hookform/resolvers": "^3.9.1", 14 | "@radix-ui/react-dialog": "^1.1.4", 15 | "@radix-ui/react-icons": "^1.3.2", 16 | "@radix-ui/react-label": "^2.1.1", 17 | "@radix-ui/react-popover": "^1.1.4", 18 | "@radix-ui/react-slot": "^1.1.1", 19 | "@radix-ui/react-tabs": "^1.1.2", 20 | "@shikijs/rehype": "^3.7.0", 21 | "class-variance-authority": "^0.7.1", 22 | "clsx": "^2.1.1", 23 | "cmdk": "1.0.0", 24 | "lucide-react": "^0.468.0", 25 | "next": "15.1.0", 26 | "next-themes": "^0.4.4", 27 | "react": "^19.0.0", 28 | "react-dom": "^19.0.0", 29 | "remark": "^15.0.1", 30 | "shiki": "^3.7.0", 31 | "tailwind-merge": "^2.5.5", 32 | "tailwindcss-animate": "^1.0.7", 33 | "vaul": "^1.1.2" 34 | }, 35 | "devDependencies": { 36 | "@eslint/eslintrc": "^3", 37 | "@types/node": "^20", 38 | "@types/react": "^19", 39 | "@types/react-dom": "^19", 40 | "eslint": "^9", 41 | "eslint-config-next": "15.1.0", 42 | "postcss": "^8", 43 | "rehype-autolink-headings": "^7.1.0", 44 | "rehype-katex": "^7.0.0", 45 | "rehype-slug": "^6.0.0", 46 | "remark-gfm": "^4.0.0", 47 | "remark-math": "^6.0.0", 48 | "tailwindcss": "^3.4.1", 49 | "typescript": "^5", 50 | "velite": "^0.2.2" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /velite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig, s } from "velite"; 2 | import rehypeSlug from "rehype-slug"; 3 | import rehypeKatex from "rehype-katex"; 4 | import remarkMath from "remark-math"; 5 | import remarkGfm from "remark-gfm"; 6 | import rehypeShiki from "@shikijs/rehype"; 7 | import rehypeAutolinkHeadings from "rehype-autolink-headings"; 8 | 9 | const computedFields = (data: T) => ({ 10 | ...data, 11 | slugAsParams: data.slug.split("/").slice(1).join("/"), 12 | }); 13 | 14 | export default defineConfig({ 15 | root: "content", 16 | output: { 17 | data: ".velite", 18 | assets: "public/static", 19 | base: "/static/", 20 | name: "[name]-[hash:6].[ext]", 21 | clean: true, 22 | }, 23 | collections: { 24 | snippets: { 25 | name: "Snippets", 26 | pattern: "snippets/**/*.mdx", 27 | schema: s 28 | .object({ 29 | slug: s.path(), 30 | title: s.string().max(99), 31 | description: s.string().max(999), 32 | code: s.string(), 33 | body: s.mdx(), 34 | toc: s.toc(), 35 | }) 36 | .transform(computedFields), 37 | }, 38 | }, 39 | mdx: { 40 | rehypePlugins: [ 41 | rehypeSlug, 42 | rehypeKatex, 43 | [ 44 | rehypeShiki, 45 | { 46 | theme: "github-dark-high-contrast", 47 | }, 48 | ], 49 | [ 50 | rehypeAutolinkHeadings, 51 | { 52 | properties: { 53 | className: ["subheading-anchor"], 54 | ariaLabel: "Link to section", 55 | }, 56 | }, 57 | ], 58 | ], 59 | remarkPlugins: [remarkMath, remarkGfm], 60 | }, 61 | }); 62 | -------------------------------------------------------------------------------- /src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import { Manrope, Poppins } from "next/font/google"; 3 | import "@/styles/globals.css"; 4 | import { siteConfig } from "@/config/site.config"; 5 | import { cn } from "@/lib/utils"; 6 | import RootProviders from "@/components/providers"; 7 | 8 | const fontSans = Manrope({ 9 | subsets: ["latin"], 10 | variable: "--font-sans", 11 | }); 12 | 13 | const fontHeading = Poppins({ 14 | subsets: ["latin"], 15 | weight: "400", 16 | variable: "--font-heading", 17 | }); 18 | 19 | export const metadata: Metadata = { 20 | metadataBase: new URL(siteConfig.origin), 21 | title: siteConfig.title, 22 | description: siteConfig.description, 23 | keywords: siteConfig.keywords, 24 | creator: siteConfig.name, 25 | icons: { 26 | icon: "/favicon.png", 27 | shortcut: "/favicon.png", 28 | }, 29 | openGraph: { 30 | title: siteConfig.title, 31 | description: siteConfig.description, 32 | url: siteConfig.origin, 33 | siteName: siteConfig.name, 34 | images: [ 35 | { 36 | url: siteConfig.og, 37 | width: 2880, 38 | height: 1800, 39 | alt: siteConfig.name, 40 | }, 41 | ], 42 | type: "website", 43 | locale: "en_US", 44 | }, 45 | twitter: { 46 | card: "summary_large_image", 47 | site: siteConfig.socials.x, 48 | title: siteConfig.title, 49 | description: siteConfig.description, 50 | images: { 51 | url: siteConfig.og, 52 | width: 2880, 53 | height: 1800, 54 | alt: siteConfig.name, 55 | }, 56 | }, 57 | }; 58 | 59 | export default function RootLayout({ 60 | children, 61 | }: Readonly<{ 62 | children: React.ReactNode; 63 | }>) { 64 | return ( 65 | 66 | 73 | {children} 74 | 75 | 76 | ); 77 | } 78 | -------------------------------------------------------------------------------- /src/components/ui/tabs.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as TabsPrimitive from "@radix-ui/react-tabs" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const Tabs = TabsPrimitive.Root 9 | 10 | const TabsList = React.forwardRef< 11 | React.ElementRef, 12 | React.ComponentPropsWithoutRef 13 | >(({ className, ...props }, ref) => ( 14 | 22 | )) 23 | TabsList.displayName = TabsPrimitive.List.displayName 24 | 25 | const TabsTrigger = React.forwardRef< 26 | React.ElementRef, 27 | React.ComponentPropsWithoutRef 28 | >(({ className, ...props }, ref) => ( 29 | 37 | )) 38 | TabsTrigger.displayName = TabsPrimitive.Trigger.displayName 39 | 40 | const TabsContent = React.forwardRef< 41 | React.ElementRef, 42 | React.ComponentPropsWithoutRef 43 | >(({ className, ...props }, ref) => ( 44 | 52 | )) 53 | TabsContent.displayName = TabsPrimitive.Content.displayName 54 | 55 | export { Tabs, TabsList, TabsTrigger, TabsContent } 56 | -------------------------------------------------------------------------------- /src/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { cva, type VariantProps } from "class-variance-authority" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", 9 | { 10 | variants: { 11 | variant: { 12 | default: 13 | "bg-primary text-primary-foreground shadow hover:bg-primary/90", 14 | destructive: 15 | "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", 16 | outline: 17 | "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground", 18 | secondary: 19 | "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", 20 | ghost: "hover:bg-accent hover:text-accent-foreground", 21 | link: "text-primary underline-offset-4 hover:underline", 22 | }, 23 | size: { 24 | default: "h-9 px-4 py-2", 25 | sm: "h-8 rounded-md px-3 text-xs", 26 | lg: "h-10 rounded-md px-8", 27 | icon: "h-9 w-9", 28 | }, 29 | }, 30 | defaultVariants: { 31 | variant: "default", 32 | size: "default", 33 | }, 34 | } 35 | ) 36 | 37 | export interface ButtonProps 38 | extends React.ButtonHTMLAttributes, 39 | VariantProps { 40 | asChild?: boolean 41 | } 42 | 43 | const Button = React.forwardRef( 44 | ({ className, variant, size, asChild = false, ...props }, ref) => { 45 | const Comp = asChild ? Slot : "button" 46 | return ( 47 | 52 | ) 53 | } 54 | ) 55 | Button.displayName = "Button" 56 | 57 | export { Button, buttonVariants } 58 | -------------------------------------------------------------------------------- /src/components/ui/card.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | const Card = React.forwardRef< 6 | HTMLDivElement, 7 | React.HTMLAttributes 8 | >(({ className, ...props }, ref) => ( 9 |
17 | )) 18 | Card.displayName = "Card" 19 | 20 | const CardHeader = React.forwardRef< 21 | HTMLDivElement, 22 | React.HTMLAttributes 23 | >(({ className, ...props }, ref) => ( 24 |
29 | )) 30 | CardHeader.displayName = "CardHeader" 31 | 32 | const CardTitle = React.forwardRef< 33 | HTMLDivElement, 34 | React.HTMLAttributes 35 | >(({ className, ...props }, ref) => ( 36 |
41 | )) 42 | CardTitle.displayName = "CardTitle" 43 | 44 | const CardDescription = React.forwardRef< 45 | HTMLDivElement, 46 | React.HTMLAttributes 47 | >(({ className, ...props }, ref) => ( 48 |
53 | )) 54 | CardDescription.displayName = "CardDescription" 55 | 56 | const CardContent = React.forwardRef< 57 | HTMLDivElement, 58 | React.HTMLAttributes 59 | >(({ className, ...props }, ref) => ( 60 |
61 | )) 62 | CardContent.displayName = "CardContent" 63 | 64 | const CardFooter = React.forwardRef< 65 | HTMLDivElement, 66 | React.HTMLAttributes 67 | >(({ className, ...props }, ref) => ( 68 |
73 | )) 74 | CardFooter.displayName = "CardFooter" 75 | 76 | export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } 77 | -------------------------------------------------------------------------------- /src/components/examples/async-select-example.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { User, searchUsers } from "@/app/actions"; 4 | import { AsyncSelect } from "@/components/async-select"; 5 | import { useState } from "react"; 6 | 7 | export default function AsyncSelectExample() { 8 | const [selectedUser, setSelectedUser] = useState(""); 9 | 10 | return ( 11 | 12 | fetcher={searchUsers} 13 | renderOption={(user) => ( 14 |
15 | {/* {user.name} */} 22 |
23 | {user.name.charAt(0)} 24 |
25 |
26 |
{user.name}
27 |
{user.role}
28 |
29 |
30 | )} 31 | getOptionValue={(user) => user.id} 32 | getDisplayValue={(user) => ( 33 |
34 | {/* {user.name} */} 41 |
42 | {user.name.charAt(0)} 43 |
44 |
45 |
{user.name}
46 |
{user.role}
47 |
48 |
49 | )} 50 | notFound={
No users found
} 51 | label="User" 52 | placeholder="Search users..." 53 | value={selectedUser} 54 | onChange={setSelectedUser} 55 | width="350px" 56 | /> 57 | ) 58 | } -------------------------------------------------------------------------------- /src/components/examples/async-select-preload-example.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { User, searchAllUsers } from "@/app/actions"; 4 | import { AsyncSelect } from "@/components/async-select"; 5 | import { useState } from "react"; 6 | 7 | export default function AsyncSelectExample() { 8 | const [selectedUser, setSelectedUser] = useState(""); 9 | 10 | return ( 11 | 12 | fetcher={searchAllUsers} 13 | preload 14 | filterFn={(user, query) => user.name.toLowerCase().includes(query.toLowerCase())} 15 | renderOption={(user) => ( 16 |
17 | {/* {user.name} */} 24 |
25 | {user.name.charAt(0)} 26 |
27 |
28 |
{user.name}
29 |
{user.role}
30 |
31 |
32 | )} 33 | getOptionValue={(user) => user.id} 34 | getDisplayValue={(user) => ( 35 |
36 | {/* {user.name} */} 43 |
44 | {user.name.charAt(0)} 45 |
46 |
47 |
{user.name}
48 |
{user.role}
49 |
50 |
51 | )} 52 | notFound={
No users found
} 53 | label="User" 54 | placeholder="Search users..." 55 | value={selectedUser} 56 | onChange={setSelectedUser} 57 | width="350px" 58 | /> 59 | ) 60 | } -------------------------------------------------------------------------------- /content/snippets/async-select-example.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Async Select 3 | description: Async Select component built with React & shadcn/ui 4 | code: | 5 | 6 | fetcher={searchUsers} 7 | renderOption={(user) => ( 8 |
9 | {user.name} 16 |
17 |
{user.name}
18 |
{user.role}
19 |
20 |
21 | )} 22 | getOptionValue={(user) => user.id} 23 | getDisplayValue={(user) => ( 24 |
25 | {user.name} 32 |
33 |
{user.name}
34 |
{user.role}
35 |
36 |
37 | )} 38 | notFound={
No users found
} 39 | label="User" 40 | placeholder="Search users..." 41 | value={selectedUser} 42 | onChange={setSelectedUser} 43 | width="375px" 44 | /> 45 | --- 46 | 47 | ```tsx 48 | 49 | fetcher={searchUsers} 50 | renderOption={(user) => ( 51 |
52 | {user.name} 59 |
60 |
{user.name}
61 |
{user.role}
62 |
63 |
64 | )} 65 | getOptionValue={(user) => user.id} 66 | getDisplayValue={(user) => ( 67 |
68 | {user.name} 75 |
76 |
{user.name}
77 |
{user.role}
78 |
79 |
80 | )} 81 | notFound={
No users found
} 82 | label="User" 83 | placeholder="Search users..." 84 | value={selectedUser} 85 | onChange={setSelectedUser} 86 | width="375px" 87 | /> 88 | ``` 89 | -------------------------------------------------------------------------------- /src/components/wrapper.tsx: -------------------------------------------------------------------------------- 1 | import { snippets } from "#site/content"; 2 | import { Label } from "@/components/ui/label"; 3 | import { CopyButton } from "@/components/copy-button"; 4 | import { 5 | Drawer, 6 | DrawerContent, 7 | DrawerDescription, 8 | DrawerHeader, 9 | DrawerTitle, 10 | DrawerTrigger, 11 | } from "@/components/ui/drawer"; 12 | import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; 13 | import { MDXContentRenderer } from "@/components/mdx/mdx-content-renderer"; 14 | 15 | export const ComponentWrapper = ({ 16 | Component, 17 | label, 18 | slug, 19 | Footer, 20 | }: { 21 | Component: React.ElementType; 22 | label?: string; 23 | slug: string; 24 | Footer?: React.ElementType; 25 | }) => { 26 | const filteredSnippets = snippets.filter(snippet => snippet.slugAsParams === slug); 27 | const exampleSnippets = snippets.filter(snippet => snippet.slugAsParams === slug + "-example"); 28 | if (filteredSnippets.length === 0 && exampleSnippets.length === 0) { 29 | return null; 30 | } 31 | const snippet = filteredSnippets[0]; 32 | const exampleSnippet = exampleSnippets[0]; 33 | return ( 34 |
35 |
36 | 37 |
38 | 39 | {snippet.code} 40 | 41 |
42 | 43 | 44 | 45 | 46 | 47 | 48 | {snippet.title} 49 | 50 | {snippet.description} 51 | 52 | 53 |
54 | 55 | 56 | Component 57 | Example 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 |
67 |
68 |
69 |
70 |
71 | 72 | {Footer &&
} 73 |
74 | ); 75 | }; -------------------------------------------------------------------------------- /content/snippets/async-select-preload-example.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Async Select with preload 3 | description: Async Select component with preloaded options built with React & shadcn/ui 4 | code: | 5 | 6 | fetcher={searchAllUsers} 7 | preload 8 | filterFn={(user, query) => user.name.toLowerCase().includes(query.toLowerCase())} 9 | renderOption={(user) => ( 10 |
11 | {user.name} 18 |
19 |
{user.name}
20 |
{user.role}
21 |
22 |
23 | )} 24 | getOptionValue={(user) => user.id} 25 | getDisplayValue={(user) => ( 26 |
27 | {user.name} 34 |
35 |
{user.name}
36 |
{user.role}
37 |
38 |
39 | )} 40 | notFound={
No users found
} 41 | label="User" 42 | placeholder="Search users..." 43 | value={selectedUser} 44 | onChange={setSelectedUser} 45 | width="375px" 46 | /> 47 | --- 48 | 49 | ```tsx 50 | 51 | fetcher={searchAllUsers} 52 | preload 53 | filterFn={(user, query) => user.name.toLowerCase().includes(query.toLowerCase())} 54 | renderOption={(user) => ( 55 |
56 | {user.name} 63 |
64 |
{user.name}
65 |
{user.role}
66 |
67 |
68 | )} 69 | getOptionValue={(user) => user.id} 70 | getDisplayValue={(user) => ( 71 |
72 | {user.name} 79 |
80 |
{user.name}
81 |
{user.role}
82 |
83 |
84 | )} 85 | notFound={
No users found
} 86 | label="User" 87 | placeholder="Search users..." 88 | value={selectedUser} 89 | onChange={setSelectedUser} 90 | width="375px" 91 | /> 92 | ``` -------------------------------------------------------------------------------- /src/components/mdx/toc.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | 5 | import { cn } from "@/lib/utils" 6 | import { useMounted } from "@/hooks/use-mounted" 7 | 8 | interface TocEntry { 9 | items?: TocEntry[] 10 | url: string 11 | title: string 12 | } 13 | 14 | interface TocProps { 15 | toc: TocEntry[] 16 | } 17 | 18 | export function DashboardTableOfContents({ toc }: TocProps) { 19 | const itemIds = React.useMemo( 20 | () => 21 | toc 22 | ? toc 23 | .flatMap((item) => [item.url, item?.items?.map((item) => item.url)]) 24 | .flat() 25 | .filter(Boolean) 26 | .map((id) => id?.split("#")[1]) 27 | : [], 28 | [toc] 29 | ) 30 | const activeHeading = useActiveItem(itemIds) 31 | const mounted = useMounted() 32 | 33 | return mounted ? ( 34 |
35 |

Table of contents

36 | 37 |
38 | ) : null 39 | } 40 | 41 | function useActiveItem(itemIds: (string | undefined)[]) { 42 | const [activeId, setActiveId] = React.useState("") 43 | 44 | React.useEffect(() => { 45 | const observer = new IntersectionObserver( 46 | (entries) => { 47 | entries.forEach((entry) => { 48 | if (entry.isIntersecting) { 49 | setActiveId(entry.target.id) 50 | } 51 | }) 52 | }, 53 | { rootMargin: `0% 0% -50% 0%` } 54 | ) 55 | 56 | itemIds?.forEach((id) => { 57 | if (!id) { 58 | return 59 | } 60 | 61 | const element = document.getElementById(id) 62 | if (element) { 63 | observer.observe(element) 64 | } 65 | }) 66 | 67 | return () => { 68 | itemIds?.forEach((id) => { 69 | if (!id) { 70 | return 71 | } 72 | 73 | const element = document.getElementById(id) 74 | if (element) { 75 | observer.unobserve(element) 76 | } 77 | }) 78 | } 79 | }, [itemIds]) 80 | 81 | return activeId 82 | } 83 | 84 | interface TreeProps { 85 | tree: TocEntry[] 86 | level?: number 87 | activeItem?: string | null 88 | } 89 | 90 | function Tree({ tree, level = 1, activeItem }: TreeProps) { 91 | return tree.length && level < 3 ? ( 92 |
    93 | {tree.map((item, index) => { 94 | return ( 95 |
  • 96 | 105 | {item.title} 106 | 107 | {item.items?.length ? ( 108 | 113 | ) : null} 114 |
  • 115 | ) 116 | })} 117 |
118 | ) : null 119 | } -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "tailwindcss"; 2 | import { fontFamily } from "tailwindcss/defaultTheme"; 3 | import animatePlugin from "tailwindcss-animate"; 4 | 5 | export default { 6 | darkMode: ["class"], 7 | content: [ 8 | "./src/pages/**/*.{js,ts,jsx,tsx,mdx}", 9 | "./src/components/**/*.{js,ts,jsx,tsx,mdx}", 10 | "./src/app/**/*.{js,ts,jsx,tsx,mdx}", 11 | ], 12 | theme: { 13 | extend: { 14 | colors: { 15 | background: 'hsl(var(--background))', 16 | foreground: 'hsl(var(--foreground))', 17 | card: { 18 | DEFAULT: 'hsl(var(--card))', 19 | foreground: 'hsl(var(--card-foreground))' 20 | }, 21 | popover: { 22 | DEFAULT: 'hsl(var(--popover))', 23 | foreground: 'hsl(var(--popover-foreground))' 24 | }, 25 | primary: { 26 | DEFAULT: 'hsl(var(--primary))', 27 | foreground: 'hsl(var(--primary-foreground))' 28 | }, 29 | secondary: { 30 | DEFAULT: 'hsl(var(--secondary))', 31 | foreground: 'hsl(var(--secondary-foreground))' 32 | }, 33 | muted: { 34 | DEFAULT: 'hsl(var(--muted))', 35 | foreground: 'hsl(var(--muted-foreground))' 36 | }, 37 | accent: { 38 | DEFAULT: 'hsl(var(--accent))', 39 | foreground: 'hsl(var(--accent-foreground))' 40 | }, 41 | destructive: { 42 | DEFAULT: 'hsl(var(--destructive))', 43 | foreground: 'hsl(var(--destructive-foreground))' 44 | }, 45 | border: 'hsl(var(--border))', 46 | input: 'hsl(var(--input))', 47 | ring: 'hsl(var(--ring))', 48 | chart: { 49 | '1': 'hsl(var(--chart-1))', 50 | '2': 'hsl(var(--chart-2))', 51 | '3': 'hsl(var(--chart-3))', 52 | '4': 'hsl(var(--chart-4))', 53 | '5': 'hsl(var(--chart-5))' 54 | }, 55 | sidebar: { 56 | DEFAULT: 'hsl(var(--sidebar-background))', 57 | foreground: 'hsl(var(--sidebar-foreground))', 58 | primary: 'hsl(var(--sidebar-primary))', 59 | 'primary-foreground': 'hsl(var(--sidebar-primary-foreground))', 60 | accent: 'hsl(var(--sidebar-accent))', 61 | 'accent-foreground': 'hsl(var(--sidebar-accent-foreground))', 62 | border: 'hsl(var(--sidebar-border))', 63 | ring: 'hsl(var(--sidebar-ring))' 64 | } 65 | }, 66 | borderRadius: { 67 | lg: 'var(--radius)', 68 | md: 'calc(var(--radius) - 2px)', 69 | sm: 'calc(var(--radius) - 4px)' 70 | }, 71 | fontFamily: { 72 | sans: ["var(--font-sans)", ...fontFamily.sans], 73 | heading: ["var(--font-heading)", ...fontFamily.sans], 74 | }, 75 | fontSize: { 76 | xxs: '0.625rem', 77 | }, 78 | keyframes: { 79 | 'accordion-down': { 80 | from: { 81 | height: '0' 82 | }, 83 | to: { 84 | height: 'var(--radix-accordion-content-height)' 85 | } 86 | }, 87 | 'accordion-up': { 88 | from: { 89 | height: 'var(--radix-accordion-content-height)' 90 | }, 91 | to: { 92 | height: '0' 93 | } 94 | } 95 | }, 96 | animation: { 97 | 'accordion-down': 'accordion-down 0.2s ease-out', 98 | 'accordion-up': 'accordion-up 0.2s ease-out' 99 | } 100 | } 101 | }, 102 | plugins: [animatePlugin], 103 | } satisfies Config; 104 | -------------------------------------------------------------------------------- /src/components/ui/drawer.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import { Drawer as DrawerPrimitive } from "vaul" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const Drawer = ({ 9 | shouldScaleBackground = true, 10 | ...props 11 | }: React.ComponentProps) => ( 12 | 16 | ) 17 | Drawer.displayName = "Drawer" 18 | 19 | const DrawerTrigger = DrawerPrimitive.Trigger 20 | 21 | const DrawerPortal = DrawerPrimitive.Portal 22 | 23 | const DrawerClose = DrawerPrimitive.Close 24 | 25 | const DrawerOverlay = React.forwardRef< 26 | React.ElementRef, 27 | React.ComponentPropsWithoutRef 28 | >(({ className, ...props }, ref) => ( 29 | 34 | )) 35 | DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName 36 | 37 | const DrawerContent = React.forwardRef< 38 | React.ElementRef, 39 | React.ComponentPropsWithoutRef 40 | >(({ className, children, ...props }, ref) => ( 41 | 42 | 43 | 51 |
52 | {children} 53 | 54 | 55 | )) 56 | DrawerContent.displayName = "DrawerContent" 57 | 58 | const DrawerHeader = ({ 59 | className, 60 | ...props 61 | }: React.HTMLAttributes) => ( 62 |
66 | ) 67 | DrawerHeader.displayName = "DrawerHeader" 68 | 69 | const DrawerFooter = ({ 70 | className, 71 | ...props 72 | }: React.HTMLAttributes) => ( 73 |
77 | ) 78 | DrawerFooter.displayName = "DrawerFooter" 79 | 80 | const DrawerTitle = React.forwardRef< 81 | React.ElementRef, 82 | React.ComponentPropsWithoutRef 83 | >(({ className, ...props }, ref) => ( 84 | 92 | )) 93 | DrawerTitle.displayName = DrawerPrimitive.Title.displayName 94 | 95 | const DrawerDescription = React.forwardRef< 96 | React.ElementRef, 97 | React.ComponentPropsWithoutRef 98 | >(({ className, ...props }, ref) => ( 99 | 104 | )) 105 | DrawerDescription.displayName = DrawerPrimitive.Description.displayName 106 | 107 | export { 108 | Drawer, 109 | DrawerPortal, 110 | DrawerOverlay, 111 | DrawerTrigger, 112 | DrawerClose, 113 | DrawerContent, 114 | DrawerHeader, 115 | DrawerFooter, 116 | DrawerTitle, 117 | DrawerDescription, 118 | } 119 | -------------------------------------------------------------------------------- /src/app/(marketing)/page.tsx: -------------------------------------------------------------------------------- 1 | import ThemeToggler from "@/components/theme/toggler"; 2 | import { siteConfig } from "@/config/site.config"; 3 | import AsyncSelectExample from "@/components/examples/async-select-example"; 4 | import AsyncSelectPreloadExample from "@/components/examples/async-select-preload-example"; 5 | import { Button } from "@/components/ui/button"; 6 | import { CodeIcon, GitHubLogoIcon } from "@radix-ui/react-icons"; 7 | import { ComponentWrapper } from "@/components/wrapper"; 8 | import { Docs } from "@/components/docs"; 9 | 10 | export default function Home() { 11 | return ( 12 |
13 |
14 |
15 |

Async Select

16 | 17 |
18 |

19 | Async Select component built with React &{" "} 20 | 25 | shadcn/ui 26 | 27 |

28 |
29 |
30 | ( 35 | 36 | Async select component with search functionality 37 | 38 | )} 39 | /> 40 |
41 |
42 | ( 47 | 48 | Async select component with preloaded options, with local 49 | filtering. 50 | 51 | )} 52 | /> 53 |
54 |
55 |
56 | 57 |
58 |
59 |
60 | ); 61 | } 62 | 63 | function Socials() { 64 | return ( 65 |
66 | 67 | 83 | 93 | 107 |
108 | ); 109 | } 110 | -------------------------------------------------------------------------------- /src/components/ui/dialog.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as DialogPrimitive from "@radix-ui/react-dialog" 5 | import { X } from "lucide-react" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const Dialog = DialogPrimitive.Root 10 | 11 | const DialogTrigger = DialogPrimitive.Trigger 12 | 13 | const DialogPortal = DialogPrimitive.Portal 14 | 15 | const DialogClose = DialogPrimitive.Close 16 | 17 | const DialogOverlay = React.forwardRef< 18 | React.ElementRef, 19 | React.ComponentPropsWithoutRef 20 | >(({ className, ...props }, ref) => ( 21 | 29 | )) 30 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName 31 | 32 | const DialogContent = React.forwardRef< 33 | React.ElementRef, 34 | React.ComponentPropsWithoutRef 35 | >(({ className, children, ...props }, ref) => ( 36 | 37 | 38 | 46 | {children} 47 | 48 | 49 | Close 50 | 51 | 52 | 53 | )) 54 | DialogContent.displayName = DialogPrimitive.Content.displayName 55 | 56 | const DialogHeader = ({ 57 | className, 58 | ...props 59 | }: React.HTMLAttributes) => ( 60 |
67 | ) 68 | DialogHeader.displayName = "DialogHeader" 69 | 70 | const DialogFooter = ({ 71 | className, 72 | ...props 73 | }: React.HTMLAttributes) => ( 74 |
81 | ) 82 | DialogFooter.displayName = "DialogFooter" 83 | 84 | const DialogTitle = React.forwardRef< 85 | React.ElementRef, 86 | React.ComponentPropsWithoutRef 87 | >(({ className, ...props }, ref) => ( 88 | 96 | )) 97 | DialogTitle.displayName = DialogPrimitive.Title.displayName 98 | 99 | const DialogDescription = React.forwardRef< 100 | React.ElementRef, 101 | React.ComponentPropsWithoutRef 102 | >(({ className, ...props }, ref) => ( 103 | 108 | )) 109 | DialogDescription.displayName = DialogPrimitive.Description.displayName 110 | 111 | export { 112 | Dialog, 113 | DialogPortal, 114 | DialogOverlay, 115 | DialogTrigger, 116 | DialogClose, 117 | DialogContent, 118 | DialogHeader, 119 | DialogFooter, 120 | DialogTitle, 121 | DialogDescription, 122 | } 123 | -------------------------------------------------------------------------------- /src/components/mdx/components.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils"; 2 | import { CodeBlock } from "./codeblock"; 3 | import Image, { type ImageProps } from "next/image"; 4 | import { Callout } from "./callout"; 5 | 6 | export const mdxComponents = { 7 | h1: ({ className, ...props }: React.HTMLAttributes) => ( 8 |

15 | ), 16 | h2: ({ className, ...props }: React.HTMLAttributes) => ( 17 |

24 | ), 25 | h3: ({ className, ...props }: React.HTMLAttributes) => ( 26 |

33 | ), 34 | h4: ({ className, ...props }: React.HTMLAttributes) => ( 35 |

42 | ), 43 | h5: ({ className, ...props }: React.HTMLAttributes) => ( 44 |
51 | ), 52 | h6: ({ className, ...props }: React.HTMLAttributes) => ( 53 |
60 | ), 61 | a: ({ className, ...props }: React.HTMLAttributes) => ( 62 | 66 | ), 67 | p: ({ 68 | className, 69 | ...props 70 | }: React.HTMLAttributes) => ( 71 |

75 | ), 76 | ul: ({ className, ...props }: React.HTMLAttributes) => ( 77 |

    78 | ), 79 | ol: ({ className, ...props }: React.HTMLAttributes) => ( 80 |
      81 | ), 82 | li: ({ className, ...props }: React.HTMLAttributes) => ( 83 |
    1. 84 | ), 85 | blockquote: ({ 86 | className, 87 | ...props 88 | }: React.HTMLAttributes) => ( 89 |
      *]: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 | {alt} 108 | ), 109 | hr: ({ ...props }) =>
      , 110 | table: ({ 111 | className, 112 | ...props 113 | }: React.HTMLAttributes) => ( 114 |
      115 | 116 | 117 | ), 118 | tr: ({ 119 | className, 120 | ...props 121 | }: React.HTMLAttributes) => ( 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) => blog image, 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 | 29 | 30 | 31 | {children} 32 | 33 | 34 | 35 | ) 36 | } 37 | 38 | const CommandInput = React.forwardRef< 39 | React.ElementRef, 40 | React.ComponentPropsWithoutRef 41 | >(({ className, ...props }, ref) => ( 42 |
      43 | 44 | 52 |
      53 | )) 54 | 55 | CommandInput.displayName = CommandPrimitive.Input.displayName 56 | 57 | const CommandList = React.forwardRef< 58 | React.ElementRef, 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 | {user.name} 81 |
      82 |
      {user.name}
      83 |
      {user.role}
      84 |
      85 |
      86 | )} 87 | getOptionValue={(user) => user.id} 88 | getDisplayValue={(user) => ( 89 |
      90 | {user.name} 97 |
      98 |
      {user.name}
      99 |
      {user.role}
      100 |
      101 |
      102 | )} 103 | notFound={
      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 | {user.name} 128 |
      129 |
      {user.name}
      130 |
      {user.role}
      131 |
      132 |
      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 AsyncSelectProps { 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 | ![Async Select](./public/og.png) 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 | {user.name} 81 |
      82 |
      {user.name}
      83 |
      {user.role}
      84 |
      85 |
      86 | )} 87 | getOptionValue={(user) => user.id} 88 | getDisplayValue={(user) => ( 89 |
      90 | {user.name} 97 |
      98 |
      {user.name}
      99 |
      {user.role}
      100 |
      101 |
      102 | )} 103 | notFound={
      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 | {user.name} 128 |
      129 |
      {user.name}
      130 |
      {user.role}
      131 |
      132 |
      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 AsyncSelectProps { 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 | 175 | 194 | 195 | 196 | 197 |
      198 | { 202 | setSearchTerm(value); 203 | }} 204 | /> 205 | {loading && options.length > 0 && ( 206 |
      207 | 208 |
      209 | )} 210 |
      211 | 212 | {error && ( 213 |
      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 | 230 | {renderOption(option)} 231 | 237 | 238 | ))} 239 | 240 |
      241 |
      242 |
      243 |
      244 | ); 245 | } 246 | 247 | function DefaultLoadingSkeleton() { 248 | return ( 249 | 250 | {[1, 2, 3].map((i) => ( 251 | 252 |
      253 |
      254 |
      255 |
      256 |
      257 |
      258 |
      259 | 260 | ))} 261 | 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 AsyncSelectProps { 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 | 179 | 198 | 199 | 200 | 201 |
      202 | { 206 | setSearchTerm(value); 207 | }} 208 | /> 209 | {loading && options.length > 0 && ( 210 |
      211 | 212 |
      213 | )} 214 |
      215 | 216 | {error && ( 217 |
      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 | 234 | {renderOption(option)} 235 | 241 | 242 | ))} 243 | 244 |
      245 |
      246 |
      247 |
      248 | ); 249 | } 250 | 251 | function DefaultLoadingSkeleton() { 252 | return ( 253 | 254 | {[1, 2, 3].map((i) => ( 255 | 256 |
      257 |
      258 |
      259 |
      260 |
      261 |
      262 |
      263 | 264 | ))} 265 | 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 AsyncSelectProps { 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 | 445 | 464 | 465 | 466 | 467 |
      468 | { 472 | setSearchTerm(value); 473 | }} 474 | /> 475 | {loading && options.length > 0 && ( 476 |
      477 | 478 |
      479 | )} 480 |
      481 | 482 | {error && ( 483 |
      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 | 500 | {renderOption(option)} 501 | 507 | 508 | ))} 509 | 510 |
      511 |
      512 |
      513 |
      514 | ); 515 | } 516 | 517 | function DefaultLoadingSkeleton() { 518 | return ( 519 | 520 | {[1, 2, 3].map((i) => ( 521 | 522 |
      523 |
      524 |
      525 |
      526 |
      527 |
      528 |
      529 | 530 | ))} 531 | 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 AsyncSelectProps { 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 | 179 | 198 | 199 | 200 | 201 |
      202 | { 206 | setSearchTerm(value); 207 | }} 208 | /> 209 | {loading && options.length > 0 && ( 210 |
      211 | 212 |
      213 | )} 214 |
      215 | 216 | {error && ( 217 |
      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 | 234 | {renderOption(option)} 235 | 241 | 242 | ))} 243 | 244 |
      245 |
      246 |
      247 |
      248 | ); 249 | } 250 | 251 | function DefaultLoadingSkeleton() { 252 | return ( 253 | 254 | {[1, 2, 3].map((i) => ( 255 | 256 |
      257 |
      258 |
      259 |
      260 |
      261 |
      262 |
      263 | 264 | ))} 265 | 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 AsyncSelectProps { 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 | 445 | 464 | 465 | 466 | 467 |
      468 | { 472 | setSearchTerm(value); 473 | }} 474 | /> 475 | {loading && options.length > 0 && ( 476 |
      477 | 478 |
      479 | )} 480 |
      481 | 482 | {error && ( 483 |
      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 | 500 | {renderOption(option)} 501 | 507 | 508 | ))} 509 | 510 |
      511 |
      512 |
      513 |
      514 | ); 515 | } 516 | 517 | function DefaultLoadingSkeleton() { 518 | return ( 519 | 520 | {[1, 2, 3].map((i) => ( 521 | 522 |
      523 |
      524 |
      525 |
      526 |
      527 |
      528 |
      529 | 530 | ))} 531 | 532 | ); 533 | } 534 | ``` --------------------------------------------------------------------------------