refactor: directory

This commit is contained in:
shadcn
2026-04-08 12:23:34 +04:00
parent 710cc27de7
commit 6823bad998
5 changed files with 478 additions and 147 deletions

View File

@@ -6,7 +6,7 @@ import { IconCheck, IconCopy, IconPlus } from "@tabler/icons-react"
import { useConfig } from "@/hooks/use-config"
import { useIsMobile } from "@/hooks/use-mobile"
import { copyToClipboardWithMeta } from "@/components/copy-button"
import { Button } from "@/registry/new-york-v4/ui/button"
import { Button } from "@/styles/base-nova/ui/button"
import {
Dialog,
DialogClose,
@@ -15,8 +15,7 @@ import {
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/registry/new-york-v4/ui/dialog"
} from "@/styles/base-nova/ui/dialog"
import {
Drawer,
DrawerClose,
@@ -25,42 +24,74 @@ import {
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/registry/new-york-v4/ui/drawer"
} from "@/styles/base-nova/ui/drawer"
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@/registry/new-york-v4/ui/tabs"
} from "@/styles/base-nova/ui/tabs"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/registry/new-york-v4/ui/tooltip"
} from "@/styles/base-nova/ui/tooltip"
const DirectoryAddContext = React.createContext<{
open: (registry: { name: string }) => void
}>({
open: () => {},
})
export function useDirectoryAdd() {
return React.useContext(DirectoryAddContext)
}
export function DirectoryAddButton({
registry,
}: {
registry: { name: string }
}) {
const { open } = useDirectoryAdd()
return (
<Button
size="sm"
variant="outline"
className="relative z-10"
onClick={() => open(registry)}
>
Add <IconPlus />
</Button>
)
}
export function DirectoryAddProvider({
children,
}: {
children: React.ReactNode
}) {
const [config, setConfig] = useConfig()
const [hasCopied, setHasCopied] = React.useState(false)
const [open, setOpen] = React.useState(false)
const [isOpen, setIsOpen] = React.useState(false)
const [selectedRegistry, setSelectedRegistry] = React.useState<{
name: string
} | null>(null)
const isMobile = useIsMobile()
const packageManager = config.packageManager || "pnpm"
const commands = React.useMemo(() => {
if (!selectedRegistry) return null
return {
pnpm: `pnpm dlx shadcn@latest registry add ${registry.name}`,
npm: `npx shadcn@latest registry add ${registry.name}`,
yarn: `yarn dlx shadcn@latest registry add ${registry.name}`,
bun: `bunx --bun shadcn@latest registry add ${registry.name}`,
pnpm: `pnpm dlx shadcn@latest registry add ${selectedRegistry.name}`,
npm: `npx shadcn@latest registry add ${selectedRegistry.name}`,
yarn: `yarn dlx shadcn@latest registry add ${selectedRegistry.name}`,
bun: `bunx --bun shadcn@latest registry add ${selectedRegistry.name}`,
}
}, [registry.name])
}, [selectedRegistry])
const command = commands[packageManager]
const command = commands?.[packageManager] ?? ""
React.useEffect(() => {
if (hasCopied) {
@@ -74,19 +105,23 @@ export function DirectoryAddButton({
name: "copy_registry_add_command",
properties: {
command,
registry: registry.name,
registry: selectedRegistry?.name ?? "",
},
})
setHasCopied(true)
}, [command, registry.name])
}, [command, selectedRegistry?.name])
const Trigger = (
<Button size="sm" variant="outline" className="relative z-10">
Add <IconPlus />
</Button>
const contextValue = React.useMemo(
() => ({
open: (registry: { name: string }) => {
setSelectedRegistry(registry)
setIsOpen(true)
},
}),
[]
)
const Content = (
const Content = commands ? (
<Tabs
value={packageManager}
onValueChange={(value) => {
@@ -95,30 +130,28 @@ export function DirectoryAddButton({
packageManager: value as "pnpm" | "npm" | "yarn" | "bun",
})
}}
className="gap-0 overflow-hidden rounded-lg border"
className="gap-0 overflow-hidden rounded-xl border"
>
<div className="flex items-center gap-2 border-b p-2">
<TabsList className="h-auto rounded-none bg-transparent p-0 font-mono *:data-[slot=tabs-trigger]:border *:data-[slot=tabs-trigger]:border-transparent *:data-[slot=tabs-trigger]:pt-0.5 *:data-[slot=tabs-trigger]:shadow-none! *:data-[slot=tabs-trigger]:data-[state=active]:border-input">
<div className="flex items-center gap-2 border-b p-1.5">
<TabsList className="h-auto *:data-[slot=tabs-trigger]:pt-0">
<TabsTrigger value="pnpm">pnpm</TabsTrigger>
<TabsTrigger value="npm">npm</TabsTrigger>
<TabsTrigger value="yarn">yarn</TabsTrigger>
<TabsTrigger value="bun">bun</TabsTrigger>
</TabsList>
<Tooltip>
<TooltipTrigger asChild>
<TooltipTrigger
render={
<Button
size="icon-sm"
variant="ghost"
className="ml-auto size-7 rounded-lg"
className="ml-auto"
onClick={handleCopy}
/>
}
>
{hasCopied ? (
<IconCheck className="size-4" />
) : (
<IconCopy className="size-4" />
)}
{hasCopied ? <IconCheck /> : <IconCopy />}
<span className="sr-only">Copy command</span>
</Button>
</TooltipTrigger>
<TooltipContent>
{hasCopied ? "Copied!" : "Copy command"}
@@ -135,17 +168,19 @@ export function DirectoryAddButton({
</TabsContent>
))}
</Tabs>
)
) : null
if (isMobile) {
return (
<Drawer open={open} onOpenChange={setOpen}>
<DrawerTrigger asChild>{Trigger}</DrawerTrigger>
<DirectoryAddContext value={contextValue}>
{children}
{isMobile ? (
<Drawer open={isOpen} onOpenChange={setIsOpen}>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>Add Registry</DrawerTitle>
<DrawerDescription>
Run this command to add {registry.name} to your project.
Run this command to add {selectedRegistry?.name} to your
project.
</DrawerDescription>
</DrawerHeader>
<div className="px-4">{Content}</div>
@@ -156,26 +191,25 @@ export function DirectoryAddButton({
</DrawerFooter>
</DrawerContent>
</Drawer>
)
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>{Trigger}</DialogTrigger>
<DialogContent className="dialog-ring animate-none! rounded-xl sm:max-w-md">
) : (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogContent className="animate-none! sm:max-w-md">
<DialogHeader>
<DialogTitle>Add Registry</DialogTitle>
<DialogDescription>
Run this command to add {registry.name} to your project.
Run this command to add {selectedRegistry?.name} to your
project.
</DialogDescription>
</DialogHeader>
{Content}
<DialogFooter>
<DialogClose asChild>
<Button size="sm">Done</Button>
<DialogClose render={<Button size="sm" variant="outline" />}>
Done
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
)}
</DirectoryAddContext>
)
}

View File

@@ -1,12 +1,20 @@
"use client"
import * as React from "react"
import { IconArrowUpRight } from "@tabler/icons-react"
import { usePathname } from "next/navigation"
import {
IconArrowUpRight,
IconChevronLeft,
IconChevronRight,
} from "@tabler/icons-react"
import { cn } from "@/lib/utils"
import { useSearchRegistry } from "@/hooks/use-search-registry"
import { DirectoryAddButton } from "@/components/directory-add-button"
import globalRegistries from "@/registry/directory.json"
import { Button } from "@/registry/new-york-v4/ui/button"
import {
DirectoryAddButton,
DirectoryAddProvider,
} from "@/components/directory-add-button"
import { Button, buttonVariants } from "@/styles/base-nova/ui/button"
import {
Item,
ItemActions,
@@ -17,9 +25,16 @@ import {
ItemMedia,
ItemSeparator,
ItemTitle,
} from "@/registry/new-york-v4/ui/item"
} from "@/styles/base-nova/ui/item"
import {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
} from "@/styles/base-nova/ui/pagination"
import { Skeleton } from "@/styles/base-nova/ui/skeleton"
import { SearchDirectory } from "./search-directory"
import { SearchDirectory } from "./directory-search"
function getHomepageUrl(homepage: string) {
const url = new URL(homepage)
@@ -29,15 +44,181 @@ function getHomepageUrl(homepage: string) {
return url.toString()
}
function getPageHref(pathname: string, query: string, page: number) {
const searchParams = new URLSearchParams()
if (query) {
searchParams.set("q", query)
}
if (page > 1) {
searchParams.set("page", page.toString())
}
const search = searchParams.toString()
return search ? `${pathname}?${search}` : pathname
}
function getPageNumbers(current: number, total: number) {
if (total <= 7) {
return Array.from({ length: total }, (_, i) => i + 1) as (
| number
| "ellipsis"
)[]
}
const pages: (number | "ellipsis")[] = [1]
// Show ellipsis or page 2 directly if only one number would be hidden.
if (current > 4) {
pages.push("ellipsis")
} else if (current >= 4) {
pages.push(2)
}
const start = Math.max(2, current - 1)
const end = Math.min(total - 1, current + 1)
for (let i = start; i <= end; i++) {
pages.push(i)
}
// Show ellipsis or second-to-last page directly if only one number would be hidden.
if (current < total - 3) {
pages.push("ellipsis")
} else if (current <= total - 3) {
pages.push(total - 1)
}
pages.push(total)
return pages
}
type DirectoryPaginationLinkProps = React.ComponentProps<"a"> & {
isActive?: boolean
size?: React.ComponentProps<typeof Button>["size"]
}
function DirectoryPaginationLink({
className,
isActive,
size = "icon",
...props
}: DirectoryPaginationLinkProps) {
return (
<a
aria-current={isActive ? "page" : undefined}
data-slot="pagination-link"
data-active={isActive}
className={cn(
buttonVariants({
variant: isActive ? "outline" : "ghost",
size,
}),
className
)}
{...props}
/>
)
}
function DirectoryPaginationPrevious({
className,
text = "Previous",
...props
}: DirectoryPaginationLinkProps & { text?: string }) {
return (
<DirectoryPaginationLink
aria-label="Go to previous page"
size="default"
className={cn("pl-1.5!", className)}
{...props}
>
<IconChevronLeft className="cn-rtl-flip size-4" />
<span className="hidden sm:block">{text}</span>
</DirectoryPaginationLink>
)
}
function DirectoryPaginationNext({
className,
text = "Next",
...props
}: DirectoryPaginationLinkProps & { text?: string }) {
return (
<DirectoryPaginationLink
aria-label="Go to next page"
size="default"
className={cn("pr-1.5!", className)}
{...props}
>
<span className="hidden sm:block">{text}</span>
<IconChevronRight className="cn-rtl-flip size-4" />
</DirectoryPaginationLink>
)
}
export function DirectoryList() {
const { registries } = useSearchRegistry()
const pathname = usePathname()
const {
isLoading,
paginatedRegistries,
page,
query,
registries,
totalPages,
setPage,
setQuery,
} = useSearchRegistry()
const previousHref =
page > 1 ? getPageHref(pathname, query, page - 1) : undefined
const nextHref =
page < totalPages ? getPageHref(pathname, query, page + 1) : undefined
const handlePageChange = React.useCallback(
(
event: React.MouseEvent<HTMLAnchorElement>,
targetPage: number,
disabled = false
) => {
if (
event.defaultPrevented ||
event.button !== 0 ||
event.metaKey ||
event.ctrlKey ||
event.shiftKey ||
event.altKey
) {
return
}
if (disabled || targetPage === page) {
event.preventDefault()
return
}
event.preventDefault()
void setPage(targetPage)
},
[page, setPage]
)
return (
<DirectoryAddProvider>
<div className="mt-6">
<SearchDirectory />
{isLoading ? (
<DirectoryListSkeleton />
) : (
<>
<SearchDirectory
query={query}
registriesCount={registries.length}
setQuery={setQuery}
/>
<ItemGroup className="my-8">
{registries.map((registry, index) => (
<React.Fragment key={index}>
{paginatedRegistries.map((registry, index) => (
<React.Fragment key={registry.name}>
<Item className="group/item relative gap-6 px-0">
<ItemMedia
variant="image"
@@ -72,12 +253,99 @@ export function DirectoryList() {
<DirectoryAddButton registry={registry} />
</ItemFooter>
</Item>
{index < globalRegistries.length - 1 && (
{index < paginatedRegistries.length - 1 && (
<ItemSeparator className="my-1" />
)}
</React.Fragment>
))}
</ItemGroup>
{totalPages > 1 && (
<Pagination>
<PaginationContent>
<PaginationItem>
<DirectoryPaginationPrevious
href={previousHref}
aria-disabled={page <= 1 || undefined}
tabIndex={page <= 1 ? -1 : undefined}
onClick={(event) =>
handlePageChange(event, page - 1, page <= 1)
}
className={cn(
page <= 1
? "pointer-events-none opacity-50"
: "cursor-pointer"
)}
/>
</PaginationItem>
{getPageNumbers(page, totalPages).map((p, i) =>
p === "ellipsis" ? (
<PaginationItem key={`ellipsis-${i}`}>
<PaginationEllipsis />
</PaginationItem>
) : (
<PaginationItem key={p}>
<DirectoryPaginationLink
href={getPageHref(pathname, query, p)}
isActive={p === page}
onClick={(event) => handlePageChange(event, p)}
className="cursor-pointer"
>
{p}
</DirectoryPaginationLink>
</PaginationItem>
)
)}
<PaginationItem>
<DirectoryPaginationNext
href={nextHref}
aria-disabled={page >= totalPages || undefined}
tabIndex={page >= totalPages ? -1 : undefined}
onClick={(event) =>
handlePageChange(event, page + 1, page >= totalPages)
}
className={cn(
page >= totalPages
? "pointer-events-none opacity-50"
: "cursor-pointer"
)}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
)}
</>
)}
</div>
</DirectoryAddProvider>
)
}
function DirectoryListSkeleton() {
return (
<>
<Skeleton className="h-8 w-full rounded-lg" />
<ItemGroup className="my-8">
{Array.from({ length: 5 }, (_, index) => (
<React.Fragment key={index}>
<Item className="relative items-start gap-6 px-0">
<Skeleton className="size-8 rounded-lg" />
<ItemContent>
<Skeleton className="h-4 w-32 sm:w-40" />
<Skeleton className="mt-1.5 h-4 w-full max-w-md" />
<Skeleton className="mt-1 h-4 w-3/4 max-w-sm" />
</ItemContent>
<ItemActions className="hidden self-start sm:flex">
<Skeleton className="h-7 w-16 rounded-lg" />
</ItemActions>
<ItemFooter className="justify-start gap-2 pl-16 sm:hidden">
<Skeleton className="h-9 w-20 rounded-lg" />
<Skeleton className="h-9 w-24 rounded-lg" />
</ItemFooter>
</Item>
{index < 4 && <ItemSeparator className="my-1" />}
</React.Fragment>
))}
</ItemGroup>
</>
)
}

View File

@@ -1,23 +1,24 @@
import * as React from "react"
"use client"
import { Search, X } from "lucide-react"
import { useSearchRegistry } from "@/hooks/use-search-registry"
import { Field } from "@/registry/new-york-v4/ui/field"
import { Field } from "@/styles/base-nova/ui/field"
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
} from "@/registry/new-york-v4/ui/input-group"
export const SearchDirectory = () => {
const { query, registries, setQuery } = useSearchRegistry()
const onQueryChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value
setQuery(value)
}
} from "@/styles/base-nova/ui/input-group"
export function SearchDirectory({
query,
registriesCount,
setQuery,
}: {
query: string
registriesCount: number
setQuery: (value: string | null) => void
}) {
return (
<Field>
<InputGroup>
@@ -25,14 +26,15 @@ export const SearchDirectory = () => {
<Search />
</InputGroupAddon>
<InputGroupInput
className="h-full"
placeholder="Search"
value={query}
onChange={onQueryChange}
onChange={(e) => setQuery(e.target.value)}
/>
<InputGroupAddon align="inline-end">
<span className="text-muted-foreground tabular-nums sm:text-xs">
{registries.length}{" "}
{registries.length === 1 ? "registry" : "registries"}
{registriesCount}{" "}
{registriesCount === 1 ? "registry" : "registries"}
</span>
</InputGroupAddon>
<InputGroupAddon

View File

@@ -3,14 +3,9 @@ title: Registry Directory
description: Discover community registries for shadcn/ui components and blocks.
---
import { TriangleAlertIcon } from "lucide-react"
These registries are built into the CLI with no additional configuration required. To add a component, run: `npx shadcn add @<registry>/<component>`.
<Callout
type="warning"
className="border-amber-200 bg-amber-50 font-semibold dark:border-amber-900 dark:bg-amber-950"
>
<Callout className="bg-muted font-semibold">
Community registries are maintained by third-party developers. Always review
code on installation to ensure it meets your security and quality standards.
</Callout>

View File

@@ -1,7 +1,10 @@
import { debounce, useQueryState } from "nuqs"
import { debounce, parseAsInteger, useQueryState } from "nuqs"
import { useMounted } from "@/hooks/use-mounted"
import globalRegistries from "@/registry/directory.json"
const PAGE_SIZE = 10
const normalizeQuery = (query: string) =>
query.toLowerCase().replaceAll(" ", "").replaceAll("@", "")
@@ -25,15 +28,44 @@ const searchDirectory = (query: string | null) => {
return globalRegistries.filter((registry) => finderFn(registry, query))
}
export const useSearchRegistry = () => {
export function useSearchRegistry() {
const mounted = useMounted()
const [query, setQuery] = useQueryState("q", {
defaultValue: "",
limitUrlUpdates: debounce(250),
})
const [page, setPage] = useQueryState("page", {
...parseAsInteger,
defaultValue: 1,
history: "push",
})
const currentQuery = mounted ? query : ""
const currentPageValue = mounted ? page : 1
const registries = searchDirectory(currentQuery)
const totalPages = Math.ceil(registries.length / PAGE_SIZE)
// Clamp page to valid range.
const currentPage = Math.max(1, Math.min(currentPageValue, totalPages))
const paginatedRegistries = registries.slice(
(currentPage - 1) * PAGE_SIZE,
currentPage * PAGE_SIZE
)
return {
query,
registries: searchDirectory(query),
setQuery,
isLoading: !mounted,
query: currentQuery,
setQuery: (value: string | null) => {
setQuery(value)
setPage(null)
},
registries,
paginatedRegistries,
page: currentPage,
totalPages,
setPage,
}
}