mirror of
https://github.com/shadcn-ui/ui.git
synced 2026-06-11 09:51:40 +00:00
Merge branch 'main' into feat/added-shadcn-dashboard
This commit is contained in:
@@ -10,6 +10,7 @@ import { usePresetCode } from "@/app/(app)/create/hooks/use-design-system"
|
||||
export function CopyPreset({ className }: React.ComponentProps<typeof Button>) {
|
||||
const presetCode = usePresetCode()
|
||||
const [hasCopied, setHasCopied] = React.useState(false)
|
||||
const label = hasCopied ? "Copied" : `--preset ${presetCode}`
|
||||
|
||||
React.useEffect(() => {
|
||||
if (hasCopied) {
|
||||
@@ -32,12 +33,13 @@ export function CopyPreset({ className }: React.ComponentProps<typeof Button>) {
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleCopy}
|
||||
title={label}
|
||||
className={cn(
|
||||
"touch-manipulation bg-transparent! px-2! py-0! text-sm! transition-none select-none hover:bg-muted! pointer-coarse:h-10!",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<span>{hasCopied ? "Copied" : `--preset ${presetCode}`}</span>
|
||||
<span className="block min-w-0 truncate">{label}</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -23,12 +23,12 @@ import { FontPicker } from "@/app/(app)/create/components/font-picker"
|
||||
import { IconLibraryPicker } from "@/app/(app)/create/components/icon-library-picker"
|
||||
import { MainMenu } from "@/app/(app)/create/components/main-menu"
|
||||
import { MenuColorPicker } from "@/app/(app)/create/components/menu-picker"
|
||||
import { OpenPreset } from "@/app/(app)/create/components/open-preset"
|
||||
import { RadiusPicker } from "@/app/(app)/create/components/radius-picker"
|
||||
import { RandomButton } from "@/app/(app)/create/components/random-button"
|
||||
import { ResetDialog } from "@/app/(app)/create/components/reset-button"
|
||||
import { StylePicker } from "@/app/(app)/create/components/style-picker"
|
||||
import { ThemePicker } from "@/app/(app)/create/components/theme-picker"
|
||||
import { V0Button } from "@/app/(app)/create/components/v0-button"
|
||||
import { FONT_HEADING_OPTIONS, FONTS } from "@/app/(app)/create/lib/fonts"
|
||||
import { useDesignSystemSearchParams } from "@/app/(app)/create/lib/search-params"
|
||||
|
||||
@@ -102,8 +102,12 @@ export function Customizer({
|
||||
</FieldGroup>
|
||||
</CardContent>
|
||||
<CardFooter className="flex min-w-0 gap-2 md:flex-col md:rounded-b-none md:**:[button,a]:w-full">
|
||||
<CopyPreset className="flex-1 md:flex-none" />
|
||||
<RandomButton className="flex-1 md:flex-none" />
|
||||
<CopyPreset className="min-w-0 flex-1 md:flex-none" />
|
||||
<OpenPreset
|
||||
className="max-w-20 min-w-0 flex-1 sm:max-w-none md:flex-none"
|
||||
label={isMobile ? "Open" : "Open Preset"}
|
||||
/>
|
||||
<RandomButton className="max-w-20 min-w-0 flex-1 sm:max-w-none md:flex-none" />
|
||||
<ActionMenu itemsByBase={itemsByBase} />
|
||||
<ResetDialog />
|
||||
</CardFooter>
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from "@/app/(app)/create/components/picker"
|
||||
import { useActionMenuTrigger } from "@/app/(app)/create/hooks/use-action-menu"
|
||||
import { useHistory } from "@/app/(app)/create/hooks/use-history"
|
||||
import { useOpenPresetTrigger } from "@/app/(app)/create/hooks/use-open-preset"
|
||||
import { useRandom } from "@/app/(app)/create/hooks/use-random"
|
||||
import { useReset } from "@/app/(app)/create/hooks/use-reset"
|
||||
import { useThemeToggle } from "@/app/(app)/create/hooks/use-theme-toggle"
|
||||
@@ -27,6 +28,7 @@ export function MainMenu({ className }: React.ComponentProps<typeof Button>) {
|
||||
const [isMac, setIsMac] = React.useState(false)
|
||||
const { canGoBack, canGoForward, goBack, goForward } = useHistory()
|
||||
const { openActionMenu } = useActionMenuTrigger()
|
||||
const { openPreset } = useOpenPresetTrigger()
|
||||
const { randomize } = useRandom()
|
||||
const { toggleTheme } = useThemeToggle()
|
||||
const { setShowResetDialog } = useReset()
|
||||
@@ -55,6 +57,9 @@ export function MainMenu({ className }: React.ComponentProps<typeof Button>) {
|
||||
Navigate...
|
||||
<PickerShortcut>{isMac ? "⌘P" : "Ctrl+P"}</PickerShortcut>
|
||||
</PickerItem>
|
||||
<PickerItem onClick={openPreset}>
|
||||
Open Preset... <PickerShortcut>O</PickerShortcut>
|
||||
</PickerItem>
|
||||
<PickerItem onClick={randomize}>
|
||||
Shuffle <PickerShortcut>R</PickerShortcut>
|
||||
</PickerItem>
|
||||
|
||||
200
apps/v4/app/(app)/create/components/open-preset.tsx
Normal file
200
apps/v4/app/(app)/create/components/open-preset.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import Script from "next/script"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useIsMobile } from "@/hooks/use-mobile"
|
||||
import { Button } from "@/styles/base-nova/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/styles/base-nova/ui/dialog"
|
||||
import {
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerFooter,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
DrawerTrigger,
|
||||
} from "@/styles/base-nova/ui/drawer"
|
||||
import { Field, FieldContent, FieldLabel } from "@/styles/base-nova/ui/field"
|
||||
import { Input } from "@/styles/base-nova/ui/input"
|
||||
import {
|
||||
OPEN_PRESET_FORWARD_TYPE,
|
||||
useOpenPreset,
|
||||
} from "@/app/(app)/create/hooks/use-open-preset"
|
||||
import { parsePresetInput } from "@/app/(app)/create/lib/parse-preset-input"
|
||||
import { useDesignSystemSearchParams } from "@/app/(app)/create/lib/search-params"
|
||||
|
||||
const PRESET_EXAMPLE = "b2D0wqNxT"
|
||||
const PRESET_TITLE = "Open Preset"
|
||||
const PRESET_DESCRIPTION = "Paste a preset code to load a saved configuration."
|
||||
|
||||
export function OpenPreset({
|
||||
className,
|
||||
label = "Open Preset",
|
||||
}: React.ComponentProps<typeof Button> & {
|
||||
label?: string
|
||||
}) {
|
||||
const [input, setInput] = React.useState("")
|
||||
const [, setParams] = useDesignSystemSearchParams()
|
||||
const isMobile = useIsMobile()
|
||||
const { open, setOpen } = useOpenPreset()
|
||||
|
||||
const nextPreset = React.useMemo(() => parsePresetInput(input), [input])
|
||||
const isInvalid = input.trim().length > 0 && nextPreset === null
|
||||
|
||||
const handleOpenChange = React.useCallback(
|
||||
(nextOpen: boolean) => {
|
||||
setOpen(nextOpen)
|
||||
|
||||
if (!nextOpen) {
|
||||
setInput("")
|
||||
}
|
||||
},
|
||||
[setOpen]
|
||||
)
|
||||
|
||||
const handleSubmit = React.useCallback(
|
||||
(event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault()
|
||||
|
||||
if (!nextPreset) {
|
||||
return
|
||||
}
|
||||
|
||||
setParams({ preset: nextPreset })
|
||||
handleOpenChange(false)
|
||||
},
|
||||
[handleOpenChange, nextPreset, setParams]
|
||||
)
|
||||
|
||||
const triggerClassName = cn(
|
||||
"touch-manipulation bg-transparent! px-2! py-0! text-sm! transition-none select-none hover:bg-muted! pointer-coarse:h-10!",
|
||||
className
|
||||
)
|
||||
|
||||
const desktopTrigger = (
|
||||
<Button variant="outline" className={triggerClassName} />
|
||||
)
|
||||
|
||||
const fields = (
|
||||
<Field data-invalid={isInvalid || undefined}>
|
||||
<FieldLabel htmlFor="preset-code" className="sr-only">
|
||||
Preset code
|
||||
</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="preset-code"
|
||||
value={input}
|
||||
onChange={(event) => setInput(event.target.value)}
|
||||
placeholder={`${PRESET_EXAMPLE} or --preset ${PRESET_EXAMPLE}`}
|
||||
autoCapitalize="none"
|
||||
autoCorrect="off"
|
||||
spellCheck={false}
|
||||
aria-invalid={isInvalid}
|
||||
className="h-10 md:h-8"
|
||||
/>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
)
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Drawer open={open} onOpenChange={handleOpenChange}>
|
||||
<DrawerTrigger asChild>
|
||||
<Button variant="outline" className={triggerClassName}>
|
||||
{label}
|
||||
</Button>
|
||||
</DrawerTrigger>
|
||||
<DrawerContent className="dark rounded-t-2xl!">
|
||||
<DrawerHeader>
|
||||
<DrawerTitle className="text-xl">{PRESET_TITLE}</DrawerTitle>
|
||||
<DrawerDescription>{PRESET_DESCRIPTION}</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="px-4 py-2">{fields}</div>
|
||||
<DrawerFooter>
|
||||
<Button type="submit" className="h-10" disabled={!nextPreset}>
|
||||
Open
|
||||
</Button>
|
||||
<DrawerClose asChild>
|
||||
<Button variant="outline" type="button" className="h-10">
|
||||
Cancel
|
||||
</Button>
|
||||
</DrawerClose>
|
||||
</DrawerFooter>
|
||||
</form>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogTrigger render={desktopTrigger}>{label}</DialogTrigger>
|
||||
<DialogContent className="dark">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{PRESET_TITLE}</DialogTitle>
|
||||
<DialogDescription>{PRESET_DESCRIPTION}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="py-4">{fields}</div>
|
||||
<DialogFooter>
|
||||
<DialogClose render={<Button variant="outline" type="button" />}>
|
||||
Cancel
|
||||
</DialogClose>
|
||||
<Button type="submit" disabled={!nextPreset}>
|
||||
Open
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export function OpenPresetScript() {
|
||||
return (
|
||||
<Script
|
||||
id="open-preset-listener"
|
||||
strategy="beforeInteractive"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
(function() {
|
||||
// Forward O key.
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'o' && !e.shiftKey && !e.metaKey && !e.ctrlKey && !e.altKey) {
|
||||
if (
|
||||
(e.target instanceof HTMLElement && e.target.isContentEditable) ||
|
||||
e.target instanceof HTMLInputElement ||
|
||||
e.target instanceof HTMLTextAreaElement ||
|
||||
e.target instanceof HTMLSelectElement
|
||||
) {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
if (window.parent && window.parent !== window) {
|
||||
window.parent.postMessage({
|
||||
type: '${OPEN_PRESET_FORWARD_TYPE}',
|
||||
key: e.key
|
||||
}, '*');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
})();
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { DARK_MODE_FORWARD_TYPE } from "@/app/(app)/create/components/mode-switc
|
||||
import { PreviewSwitcher } from "@/app/(app)/create/components/preview-switcher"
|
||||
import { RANDOMIZE_FORWARD_TYPE } from "@/app/(app)/create/components/random-button"
|
||||
import { sendToIframe } from "@/app/(app)/create/hooks/use-iframe-sync"
|
||||
import { OPEN_PRESET_FORWARD_TYPE } from "@/app/(app)/create/hooks/use-open-preset"
|
||||
import { RESET_FORWARD_TYPE } from "@/app/(app)/create/hooks/use-reset"
|
||||
import {
|
||||
serializeDesignSystemSearchParams,
|
||||
@@ -20,78 +21,6 @@ import {
|
||||
// Hoisted — avoids recreating on every message event. (js-hoist-regexp)
|
||||
const MAC_REGEX = /Mac|iPhone|iPad|iPod/
|
||||
|
||||
// Hoisted — only uses module-level constants, no component state. (rendering-hoist-jsx)
|
||||
function handleMessage(event: MessageEvent) {
|
||||
if (
|
||||
typeof window === "undefined" ||
|
||||
event.origin !== window.location.origin
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const type = event.data.type
|
||||
if (type === CMD_K_FORWARD_TYPE) {
|
||||
const isMac = MAC_REGEX.test(navigator.userAgent)
|
||||
document.dispatchEvent(
|
||||
new KeyboardEvent("keydown", {
|
||||
key: event.data.key || "k",
|
||||
metaKey: isMac,
|
||||
ctrlKey: !isMac,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
})
|
||||
)
|
||||
} else if (type === RANDOMIZE_FORWARD_TYPE) {
|
||||
document.dispatchEvent(
|
||||
new KeyboardEvent("keydown", {
|
||||
key: event.data.key || "r",
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
})
|
||||
)
|
||||
} else if (type === UNDO_FORWARD_TYPE) {
|
||||
const isMac = MAC_REGEX.test(navigator.userAgent)
|
||||
document.dispatchEvent(
|
||||
new KeyboardEvent("keydown", {
|
||||
key: "z",
|
||||
metaKey: isMac,
|
||||
ctrlKey: !isMac,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
})
|
||||
)
|
||||
} else if (type === REDO_FORWARD_TYPE) {
|
||||
const isMac = MAC_REGEX.test(navigator.userAgent)
|
||||
document.dispatchEvent(
|
||||
new KeyboardEvent("keydown", {
|
||||
key: "z",
|
||||
shiftKey: true,
|
||||
metaKey: isMac,
|
||||
ctrlKey: !isMac,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
})
|
||||
)
|
||||
} else if (type === RESET_FORWARD_TYPE) {
|
||||
document.dispatchEvent(
|
||||
new KeyboardEvent("keydown", {
|
||||
key: "R",
|
||||
shiftKey: true,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
})
|
||||
)
|
||||
} else if (type === DARK_MODE_FORWARD_TYPE) {
|
||||
document.dispatchEvent(
|
||||
new KeyboardEvent("keydown", {
|
||||
key: event.data.key || "d",
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export function Preview() {
|
||||
const [params] = useDesignSystemSearchParams()
|
||||
const iframeRef = React.useRef<HTMLIFrameElement>(null)
|
||||
@@ -117,6 +46,89 @@ export function Preview() {
|
||||
}, [params])
|
||||
|
||||
React.useEffect(() => {
|
||||
const handleMessage = (event: MessageEvent) => {
|
||||
const iframeWindow = iframeRef.current?.contentWindow
|
||||
if (
|
||||
!iframeWindow ||
|
||||
event.origin !== window.location.origin ||
|
||||
event.source !== iframeWindow ||
|
||||
!event.data ||
|
||||
typeof event.data !== "object"
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const type = event.data.type
|
||||
if (type === CMD_K_FORWARD_TYPE) {
|
||||
const isMac = MAC_REGEX.test(navigator.userAgent)
|
||||
document.dispatchEvent(
|
||||
new KeyboardEvent("keydown", {
|
||||
key: event.data.key || "k",
|
||||
metaKey: isMac,
|
||||
ctrlKey: !isMac,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
})
|
||||
)
|
||||
} else if (type === RANDOMIZE_FORWARD_TYPE) {
|
||||
document.dispatchEvent(
|
||||
new KeyboardEvent("keydown", {
|
||||
key: event.data.key || "r",
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
})
|
||||
)
|
||||
} else if (type === OPEN_PRESET_FORWARD_TYPE) {
|
||||
document.dispatchEvent(
|
||||
new KeyboardEvent("keydown", {
|
||||
key: event.data.key || "o",
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
})
|
||||
)
|
||||
} else if (type === UNDO_FORWARD_TYPE) {
|
||||
const isMac = MAC_REGEX.test(navigator.userAgent)
|
||||
document.dispatchEvent(
|
||||
new KeyboardEvent("keydown", {
|
||||
key: "z",
|
||||
metaKey: isMac,
|
||||
ctrlKey: !isMac,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
})
|
||||
)
|
||||
} else if (type === REDO_FORWARD_TYPE) {
|
||||
const isMac = MAC_REGEX.test(navigator.userAgent)
|
||||
document.dispatchEvent(
|
||||
new KeyboardEvent("keydown", {
|
||||
key: "z",
|
||||
shiftKey: true,
|
||||
metaKey: isMac,
|
||||
ctrlKey: !isMac,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
})
|
||||
)
|
||||
} else if (type === RESET_FORWARD_TYPE) {
|
||||
document.dispatchEvent(
|
||||
new KeyboardEvent("keydown", {
|
||||
key: "R",
|
||||
shiftKey: true,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
})
|
||||
)
|
||||
} else if (type === DARK_MODE_FORWARD_TYPE) {
|
||||
document.dispatchEvent(
|
||||
new KeyboardEvent("keydown", {
|
||||
key: event.data.key || "d",
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("message", handleMessage)
|
||||
return () => {
|
||||
window.removeEventListener("message", handleMessage)
|
||||
|
||||
@@ -28,7 +28,7 @@ export function RandomButton({
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="w-full text-center font-medium">Shuffle</span>
|
||||
<span className="w-full truncate text-center font-medium">Shuffle</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
81
apps/v4/app/(app)/create/hooks/use-open-preset.tsx
Normal file
81
apps/v4/app/(app)/create/hooks/use-open-preset.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import useSWR from "swr"
|
||||
|
||||
const OPEN_PRESET_KEY = "create:open-preset-open"
|
||||
export const OPEN_PRESET_FORWARD_TYPE = "open-preset-forward"
|
||||
|
||||
function isEditableTarget(target: EventTarget | null) {
|
||||
return (
|
||||
(target instanceof HTMLElement && target.isContentEditable) ||
|
||||
target instanceof HTMLInputElement ||
|
||||
target instanceof HTMLTextAreaElement ||
|
||||
target instanceof HTMLSelectElement
|
||||
)
|
||||
}
|
||||
|
||||
export function useOpenPreset() {
|
||||
const { data: open = false, mutate: setOpenData } = useSWR<boolean>(
|
||||
OPEN_PRESET_KEY,
|
||||
{
|
||||
fallbackData: false,
|
||||
revalidateOnFocus: false,
|
||||
revalidateIfStale: false,
|
||||
revalidateOnReconnect: false,
|
||||
}
|
||||
)
|
||||
|
||||
const handleOpenChange = React.useCallback(
|
||||
(nextOpen: boolean) => {
|
||||
void setOpenData(nextOpen, { revalidate: false })
|
||||
},
|
||||
[setOpenData]
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
const down = (e: KeyboardEvent) => {
|
||||
if (
|
||||
e.key === "o" &&
|
||||
!e.shiftKey &&
|
||||
!e.metaKey &&
|
||||
!e.ctrlKey &&
|
||||
!e.altKey
|
||||
) {
|
||||
if (isEditableTarget(e.target)) {
|
||||
return
|
||||
}
|
||||
|
||||
e.preventDefault()
|
||||
void setOpenData(true, { revalidate: false })
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", down)
|
||||
return () => {
|
||||
document.removeEventListener("keydown", down)
|
||||
}
|
||||
}, [setOpenData])
|
||||
|
||||
return {
|
||||
open,
|
||||
setOpen: handleOpenChange,
|
||||
}
|
||||
}
|
||||
|
||||
export function useOpenPresetTrigger() {
|
||||
const { mutate: setOpenData } = useSWR<boolean>(OPEN_PRESET_KEY, {
|
||||
fallbackData: false,
|
||||
revalidateOnFocus: false,
|
||||
revalidateIfStale: false,
|
||||
revalidateOnReconnect: false,
|
||||
})
|
||||
|
||||
const openPreset = React.useCallback(() => {
|
||||
void setOpenData(true, { revalidate: false })
|
||||
}, [setOpenData])
|
||||
|
||||
return {
|
||||
openPreset,
|
||||
}
|
||||
}
|
||||
17
apps/v4/app/(app)/create/lib/parse-preset-input.test.ts
Normal file
17
apps/v4/app/(app)/create/lib/parse-preset-input.test.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
import { parsePresetInput } from "./parse-preset-input"
|
||||
|
||||
describe("parsePresetInput", () => {
|
||||
it("accepts a raw preset code", () => {
|
||||
expect(parsePresetInput("b0")).toBe("b0")
|
||||
})
|
||||
|
||||
it("accepts a --preset flag", () => {
|
||||
expect(parsePresetInput(" --preset b0 ")).toBe("b0")
|
||||
})
|
||||
|
||||
it("rejects invalid preset input", () => {
|
||||
expect(parsePresetInput("open sesame")).toBeNull()
|
||||
})
|
||||
})
|
||||
15
apps/v4/app/(app)/create/lib/parse-preset-input.ts
Normal file
15
apps/v4/app/(app)/create/lib/parse-preset-input.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { isPresetCode } from "shadcn/preset"
|
||||
|
||||
const PRESET_FLAG_PATTERN = /^--preset\b\s+(.+)$/i
|
||||
|
||||
export function parsePresetInput(value: string) {
|
||||
const input = value.trim()
|
||||
|
||||
if (!input) {
|
||||
return null
|
||||
}
|
||||
|
||||
const preset = input.match(PRESET_FLAG_PATTERN)?.[1]?.trim() ?? input
|
||||
|
||||
return isPresetCode(preset) ? preset : null
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { ActionMenuScript } from "@/app/(app)/create/components/action-menu"
|
||||
import { DesignSystemProvider } from "@/app/(app)/create/components/design-system-provider"
|
||||
import { HistoryScript } from "@/app/(app)/create/components/history-buttons"
|
||||
import { DarkModeScript } from "@/app/(app)/create/components/mode-switcher"
|
||||
import { OpenPresetScript } from "@/app/(app)/create/components/open-preset"
|
||||
import { PreviewStyle } from "@/app/(app)/create/components/preview-style"
|
||||
import { RandomizeScript } from "@/app/(app)/create/components/random-button"
|
||||
import {
|
||||
@@ -139,6 +140,7 @@ export default async function BlockPage({
|
||||
<PreventScrollOnFocusScript />
|
||||
<PreviewStyle />
|
||||
<ActionMenuScript />
|
||||
<OpenPresetScript />
|
||||
<RandomizeScript />
|
||||
<HistoryScript />
|
||||
<DarkModeScript />
|
||||
|
||||
@@ -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>
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
className="ml-auto size-7 rounded-lg"
|
||||
onClick={handleCopy}
|
||||
>
|
||||
{hasCopied ? (
|
||||
<IconCheck className="size-4" />
|
||||
) : (
|
||||
<IconCopy className="size-4" />
|
||||
)}
|
||||
<span className="sr-only">Copy command</span>
|
||||
</Button>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
className="ml-auto"
|
||||
onClick={handleCopy}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{hasCopied ? <IconCheck /> : <IconCopy />}
|
||||
<span className="sr-only">Copy command</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{hasCopied ? "Copied!" : "Copy command"}
|
||||
@@ -135,47 +168,48 @@ export function DirectoryAddButton({
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
)
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Drawer open={open} onOpenChange={setOpen}>
|
||||
<DrawerTrigger asChild>{Trigger}</DrawerTrigger>
|
||||
<DrawerContent>
|
||||
<DrawerHeader>
|
||||
<DrawerTitle>Add Registry</DrawerTitle>
|
||||
<DrawerDescription>
|
||||
Run this command to add {registry.name} to your project.
|
||||
</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
<div className="px-4">{Content}</div>
|
||||
<DrawerFooter>
|
||||
<DrawerClose asChild>
|
||||
<Button size="sm">Done</Button>
|
||||
</DrawerClose>
|
||||
</DrawerFooter>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
) : null
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>{Trigger}</DialogTrigger>
|
||||
<DialogContent className="dialog-ring animate-none! rounded-xl sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add Registry</DialogTitle>
|
||||
<DialogDescription>
|
||||
Run this command to add {registry.name} to your project.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{Content}
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button size="sm">Done</Button>
|
||||
</DialogClose>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<DirectoryAddContext value={contextValue}>
|
||||
{children}
|
||||
{isMobile ? (
|
||||
<Drawer open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DrawerContent>
|
||||
<DrawerHeader>
|
||||
<DrawerTitle>Add Registry</DrawerTitle>
|
||||
<DrawerDescription>
|
||||
Run this command to add {selectedRegistry?.name} to your
|
||||
project.
|
||||
</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
<div className="px-4">{Content}</div>
|
||||
<DrawerFooter>
|
||||
<DrawerClose asChild>
|
||||
<Button size="sm">Done</Button>
|
||||
</DrawerClose>
|
||||
</DrawerFooter>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
) : (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogContent className="animate-none! sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add Registry</DialogTitle>
|
||||
<DialogDescription>
|
||||
Run this command to add {selectedRegistry?.name} to your
|
||||
project.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{Content}
|
||||
<DialogFooter>
|
||||
<DialogClose render={<Button size="sm" variant="outline" />}>
|
||||
Done
|
||||
</DialogClose>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</DirectoryAddContext>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,55 +44,308 @@ 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 (
|
||||
<div className="mt-6">
|
||||
<SearchDirectory />
|
||||
<DirectoryAddProvider>
|
||||
<div className="mt-6">
|
||||
{isLoading ? (
|
||||
<DirectoryListSkeleton />
|
||||
) : (
|
||||
<>
|
||||
<SearchDirectory
|
||||
query={query}
|
||||
registriesCount={registries.length}
|
||||
setQuery={setQuery}
|
||||
/>
|
||||
<ItemGroup className="my-8">
|
||||
{paginatedRegistries.map((registry, index) => (
|
||||
<React.Fragment key={registry.name}>
|
||||
<Item className="group/item relative gap-6 px-0">
|
||||
<ItemMedia
|
||||
variant="image"
|
||||
dangerouslySetInnerHTML={{ __html: registry.logo }}
|
||||
className="grayscale *:[svg]:size-8 *:[svg]:fill-foreground"
|
||||
/>
|
||||
<ItemContent>
|
||||
<ItemTitle>
|
||||
<a
|
||||
href={getHomepageUrl(registry.homepage)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer external"
|
||||
className="group flex items-center gap-1"
|
||||
>
|
||||
{registry.name}{" "}
|
||||
<IconArrowUpRight className="size-4 opacity-0 group-hover:opacity-100" />
|
||||
</a>
|
||||
</ItemTitle>
|
||||
{registry.description && (
|
||||
<ItemDescription className="text-pretty">
|
||||
{registry.description}
|
||||
</ItemDescription>
|
||||
)}
|
||||
</ItemContent>
|
||||
<ItemActions className="relative z-10 hidden self-start sm:flex">
|
||||
<DirectoryAddButton registry={registry} />
|
||||
</ItemActions>
|
||||
<ItemFooter className="justify-start pl-16 sm:hidden">
|
||||
<Button size="sm" variant="outline">
|
||||
View <IconArrowUpRight />
|
||||
</Button>
|
||||
<DirectoryAddButton registry={registry} />
|
||||
</ItemFooter>
|
||||
</Item>
|
||||
{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">
|
||||
{registries.map((registry, index) => (
|
||||
{Array.from({ length: 5 }, (_, index) => (
|
||||
<React.Fragment key={index}>
|
||||
<Item className="group/item relative gap-6 px-0">
|
||||
<ItemMedia
|
||||
variant="image"
|
||||
dangerouslySetInnerHTML={{ __html: registry.logo }}
|
||||
className="grayscale *:[svg]:size-8 *:[svg]:fill-foreground"
|
||||
/>
|
||||
<Item className="relative items-start gap-6 px-0">
|
||||
<Skeleton className="size-8 rounded-lg" />
|
||||
<ItemContent>
|
||||
<ItemTitle>
|
||||
<a
|
||||
href={getHomepageUrl(registry.homepage)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer external"
|
||||
className="group flex items-center gap-1"
|
||||
>
|
||||
{registry.name}{" "}
|
||||
<IconArrowUpRight className="size-4 opacity-0 group-hover:opacity-100" />
|
||||
</a>
|
||||
</ItemTitle>
|
||||
{registry.description && (
|
||||
<ItemDescription className="text-pretty">
|
||||
{registry.description}
|
||||
</ItemDescription>
|
||||
)}
|
||||
<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="relative z-10 hidden self-start sm:flex">
|
||||
<DirectoryAddButton registry={registry} />
|
||||
<ItemActions className="hidden self-start sm:flex">
|
||||
<Skeleton className="h-7 w-16 rounded-lg" />
|
||||
</ItemActions>
|
||||
<ItemFooter className="justify-start pl-16 sm:hidden">
|
||||
<Button size="sm" variant="outline">
|
||||
View <IconArrowUpRight />
|
||||
</Button>
|
||||
<DirectoryAddButton registry={registry} />
|
||||
<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 < globalRegistries.length - 1 && (
|
||||
<ItemSeparator className="my-1" />
|
||||
)}
|
||||
{index < 4 && <ItemSeparator className="my-1" />}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</ItemGroup>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -5,7 +5,7 @@ description: Use the shadcn CLI to add components to your project.
|
||||
|
||||
## init
|
||||
|
||||
Use the `init` command to initialize configuration and dependencies for a new project.
|
||||
Use the `init` command to initialize configuration and dependencies for an existing project, or create a new project with `--name`.
|
||||
|
||||
The `init` command installs dependencies, adds the `cn` util and configures CSS variables for the project.
|
||||
|
||||
@@ -21,26 +21,26 @@ Usage: shadcn init [options] [components...]
|
||||
initialize your project and install dependencies
|
||||
|
||||
Arguments:
|
||||
components name, url or local path to component
|
||||
components names, url or local path to component
|
||||
|
||||
Options:
|
||||
-t, --template <template> the template to use. (next, vite, start, react-router, laravel, astro)
|
||||
-b, --base <base> the component library to use. (radix, base)
|
||||
-p, --preset [name] use a preset configuration. (name, URL, or preset code)
|
||||
-n, --name <name> the name for the new project.
|
||||
-d, --defaults use default configuration. (default: false)
|
||||
-p, --preset [name] use a preset configuration
|
||||
-y, --yes skip confirmation prompt. (default: true)
|
||||
-d, --defaults use default configuration: --template=next --preset=nova (default: false)
|
||||
-f, --force force overwrite of existing configuration. (default: false)
|
||||
-c, --cwd <cwd> the working directory. defaults to the current directory.
|
||||
-n, --name <name> the name for the new project.
|
||||
-s, --silent mute output. (default: false)
|
||||
--monorepo scaffold a monorepo project.
|
||||
--no-monorepo skip the monorepo prompt.
|
||||
--reinstall re-install existing UI components.
|
||||
--no-reinstall do not re-install existing UI components.
|
||||
--rtl enable RTL support.
|
||||
--no-rtl disable RTL support.
|
||||
--css-variables use css variables for theming. (default: true)
|
||||
--no-css-variables do not use css variables for theming.
|
||||
--monorepo scaffold a monorepo project.
|
||||
--no-monorepo skip the monorepo prompt.
|
||||
--rtl enable RTL support.
|
||||
--no-rtl disable RTL support.
|
||||
--reinstall re-install existing UI components.
|
||||
--no-reinstall do not re-install existing UI components.
|
||||
-h, --help display help for command
|
||||
```
|
||||
|
||||
@@ -85,6 +85,34 @@ Options:
|
||||
|
||||
---
|
||||
|
||||
## apply
|
||||
|
||||
Use the `apply` command to apply a preset to an existing project.
|
||||
|
||||
```bash
|
||||
npx shadcn@latest apply --preset a2r6bw
|
||||
```
|
||||
|
||||
**Options**
|
||||
|
||||
```bash
|
||||
Usage: shadcn apply [options] [preset]
|
||||
|
||||
apply a preset to an existing project
|
||||
|
||||
Arguments:
|
||||
preset the preset to apply
|
||||
|
||||
Options:
|
||||
--preset <preset> preset configuration to apply
|
||||
-y, --yes skip confirmation prompt. (default: false)
|
||||
-c, --cwd <cwd> the working directory. defaults to the current directory.
|
||||
-s, --silent mute output. (default: false)
|
||||
-h, --help display help for command
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## view
|
||||
|
||||
Use the `view` command to view items from the registry before installing them.
|
||||
|
||||
@@ -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>
|
||||
|
||||
21
apps/v4/content/docs/changelog/2026-04-shadcn-apply.mdx
Normal file
21
apps/v4/content/docs/changelog/2026-04-shadcn-apply.mdx
Normal file
@@ -0,0 +1,21 @@
|
||||
---
|
||||
title: April 2026 - shadcn apply
|
||||
description: Switch presets in existing projects without starting over.
|
||||
date: 2026-04-08
|
||||
---
|
||||
|
||||
We added `shadcn apply` so you can switch presets in an existing project without starting over.
|
||||
|
||||
When you run `npx shadcn@latest apply` in an existing project, we apply a new preset, reinstall your existing components, and update your theme, colors, CSS variables, fonts, and icons.
|
||||
|
||||
```bash
|
||||
npx shadcn@latest apply --preset b2D0vQ7G4
|
||||
```
|
||||
|
||||
The CLI keeps the current base and RTL settings from your existing project, even when the preset URL was generated with different values.
|
||||
|
||||
<Button asChild size="sm">
|
||||
<Link href="/create" className="mt-6 no-underline!">
|
||||
Try a Preset
|
||||
</Link>
|
||||
</Button>
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,7 +76,7 @@
|
||||
"rehype-pretty-code": "^0.14.1",
|
||||
"rimraf": "^6.0.1",
|
||||
"server-only": "^0.0.1",
|
||||
"shadcn": "4.1.2",
|
||||
"shadcn": "4.2.0",
|
||||
"shiki": "^1.10.1",
|
||||
"sonner": "^2.0.0",
|
||||
"swr": "^2.3.6",
|
||||
|
||||
@@ -1030,5 +1030,11 @@
|
||||
"homepage": "https://shadcndashboard.dev",
|
||||
"url": "https://shadcndashboard.dev/r/{name}.json",
|
||||
"description": "ShadcnDashboard is a collection of modern, production-ready dashboard layouts, components, and UI patterns built on top of shadcn/ui and Tailwind CSS. It’s designed to help developers build clean, scalable, and data-driven dashboards faster—without compromising on performance, accessibility, or customization."
|
||||
},
|
||||
{
|
||||
"name": "@termcn",
|
||||
"homepage": "https://termcn.vercel.app",
|
||||
"url": "https://termcn.vercel.app/r/{name}.json",
|
||||
"description": "Beautiful terminal UIs, made simple. Ready to use, customizable terminal UI components for React."
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1203,5 +1203,12 @@
|
||||
"url": "https://shadcndashboard.dev/r/{name}.json",
|
||||
"description": "ShadcnDashboard is a collection of modern, production-ready dashboard layouts, components, and UI patterns built on top of shadcn/ui and Tailwind CSS. It’s designed to help developers build clean, scalable, and data-driven dashboards faster—without compromising on performance, accessibility, or customization.",
|
||||
"logo": "<svg width='40' height='40' viewBox='0 0 40 40' fill='none' xmlns='http://www.w3.org/2000/svg'><rect width='40' height='40' rx='20' fill='#053B25'/><path d='M32.1327 19.501C31.5178 19.6123 30.8844 19.6705 30.2375 19.6705C24.3886 19.6705 19.6471 14.9189 19.6471 9.05765C19.6471 8.89469 19.6507 8.73258 19.658 8.57141C13.8411 9.10533 9.28516 14.0074 9.28516 19.9759C9.28516 26.301 14.4019 31.4286 20.7137 31.4286C27.0256 31.4286 32.1423 26.301 32.1423 19.9759C32.1423 19.8168 32.1391 19.6585 32.1327 19.501Z' fill='#C2FD75'/></svg>"
|
||||
},
|
||||
{
|
||||
"name": "@termcn",
|
||||
"homepage": "https://termcn.vercel.app",
|
||||
"url": "https://termcn.vercel.app/r/{name}.json",
|
||||
"description": "Beautiful terminal UIs, made simple. Ready to use, customizable terminal UI components for React.",
|
||||
"logo": "<svg xmlns='http://www.w3.org/2000/svg' width='256' height='256' viewBox='0 0 256 256'><script/><rect x='16' y='16' width='224' height='224' rx='28' stroke='#000' stroke-width='7'/><path d='m42 56 32 24-32 24' stroke='#fff' stroke-width='10' stroke-linecap='round' stroke-linejoin='round' fill='none'/><path d='m208.8 156.8-48 48M199.2 104 108 195.2' stroke='#fff' stroke-width='19.2' stroke-linecap='round' fill='none'/></svg>"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# @shadcn/ui
|
||||
|
||||
## 4.2.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- [#10313](https://github.com/shadcn-ui/ui/pull/10313) [`c1e29824cd7a6809448e45b6b7fe8f7be71ecb0f`](https://github.com/shadcn-ui/ui/commit/c1e29824cd7a6809448e45b6b7fe8f7be71ecb0f) Thanks [@shadcn](https://github.com/shadcn)! - add shadcn apply command
|
||||
|
||||
## 4.1.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -20,6 +20,16 @@ The `init` command installs dependencies, adds the `cn` util, configures Tailwin
|
||||
npx shadcn init
|
||||
```
|
||||
|
||||
## apply
|
||||
|
||||
Use the `apply` command to apply a preset to an existing project.
|
||||
|
||||
The `apply` command overwrites the current preset configuration, reinstalls detected UI components, and updates fonts and CSS variables to match the new preset.
|
||||
|
||||
```bash
|
||||
npx shadcn apply --preset a2r6bw
|
||||
```
|
||||
|
||||
## add
|
||||
|
||||
Use the `add` command to add components to your project.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "shadcn",
|
||||
"version": "4.1.2",
|
||||
"version": "4.2.0",
|
||||
"description": "Add components to your apps.",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
|
||||
49
packages/shadcn/src/commands/apply.test.ts
Normal file
49
packages/shadcn/src/commands/apply.test.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { REGISTRY_URL } from "@/src/registry/constants"
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
import { resolveApplyInitUrl } from "./apply"
|
||||
|
||||
const SHADCN_URL = REGISTRY_URL.replace(/\/r\/?$/, "")
|
||||
|
||||
describe("resolveApplyInitUrl", () => {
|
||||
it("should include the inferred template for preset codes", () => {
|
||||
const initUrl = resolveApplyInitUrl("a0", "base", {
|
||||
template: "next",
|
||||
rtl: true,
|
||||
})
|
||||
const parsed = new URL(initUrl)
|
||||
|
||||
expect(parsed.origin + parsed.pathname).toBe(`${SHADCN_URL}/init`)
|
||||
expect(parsed.searchParams.get("template")).toBe("next")
|
||||
expect(parsed.searchParams.get("preset")).toBe("a0")
|
||||
expect(parsed.searchParams.get("base")).toBe("base")
|
||||
expect(parsed.searchParams.get("rtl")).toBe("true")
|
||||
})
|
||||
|
||||
it("should include the inferred template for named presets", () => {
|
||||
const initUrl = resolveApplyInitUrl("lyra", "base", {
|
||||
template: "next",
|
||||
rtl: true,
|
||||
})
|
||||
const parsed = new URL(initUrl)
|
||||
|
||||
expect(parsed.origin + parsed.pathname).toBe(`${SHADCN_URL}/init`)
|
||||
expect(parsed.searchParams.get("template")).toBe("next")
|
||||
expect(parsed.searchParams.get("base")).toBe("base")
|
||||
expect(parsed.searchParams.get("rtl")).toBe("true")
|
||||
})
|
||||
|
||||
it("should keep the current base for raw preset URLs without injecting a template", () => {
|
||||
const presetUrl = `${SHADCN_URL}/init?base=radix&style=nova&baseColor=neutral&theme=neutral&iconLibrary=lucide&font=inter&rtl=false&menuAccent=subtle&menuColor=default&radius=default`
|
||||
const initUrl = resolveApplyInitUrl(presetUrl, "base", {
|
||||
template: "next",
|
||||
rtl: true,
|
||||
})
|
||||
const parsed = new URL(initUrl)
|
||||
|
||||
expect(parsed.searchParams.get("template")).toBeNull()
|
||||
expect(parsed.searchParams.get("track")).toBe("1")
|
||||
expect(parsed.searchParams.get("base")).toBe("base")
|
||||
expect(parsed.searchParams.get("rtl")).toBe("true")
|
||||
})
|
||||
})
|
||||
309
packages/shadcn/src/commands/apply.ts
Normal file
309
packages/shadcn/src/commands/apply.ts
Normal file
@@ -0,0 +1,309 @@
|
||||
import path from "path"
|
||||
import { runInit } from "@/src/commands/init"
|
||||
import { preFlightApply } from "@/src/preflights/preflight-apply"
|
||||
import { decodePreset, isPresetCode } from "@/src/preset/preset"
|
||||
import {
|
||||
DEFAULT_PRESETS,
|
||||
promptToOpenPresetBuilder,
|
||||
resolveCreateUrl,
|
||||
resolveInitUrl,
|
||||
resolveRegistryBaseConfig,
|
||||
} from "@/src/preset/presets"
|
||||
import { SHADCN_URL } from "@/src/registry/constants"
|
||||
import { clearRegistryContext } from "@/src/registry/context"
|
||||
import { registryConfigSchema } from "@/src/registry/schema"
|
||||
import { isUrl } from "@/src/registry/utils"
|
||||
import { getTemplateForFramework } from "@/src/templates/index"
|
||||
import { loadEnvFiles } from "@/src/utils/env-loader"
|
||||
import * as ERRORS from "@/src/utils/errors"
|
||||
import { withFileBackup } from "@/src/utils/file-helper"
|
||||
import { getBase } from "@/src/utils/get-config"
|
||||
import {
|
||||
getProjectComponents,
|
||||
getProjectInfo,
|
||||
} from "@/src/utils/get-project-info"
|
||||
import { handleError } from "@/src/utils/handle-error"
|
||||
import { highlighter } from "@/src/utils/highlighter"
|
||||
import { logger } from "@/src/utils/logger"
|
||||
import { Command } from "commander"
|
||||
import prompts from "prompts"
|
||||
import { z } from "zod"
|
||||
|
||||
export const applyOptionsSchema = z.object({
|
||||
cwd: z.string(),
|
||||
positionalPreset: z.string().optional(),
|
||||
preset: z.string().optional(),
|
||||
yes: z.boolean(),
|
||||
silent: z.boolean(),
|
||||
})
|
||||
|
||||
export const apply = new Command()
|
||||
.name("apply")
|
||||
.description("apply a preset to an existing project")
|
||||
.argument("[preset]", "the preset to apply")
|
||||
.option("--preset <preset>", "preset configuration to apply")
|
||||
.option("-y, --yes", "skip confirmation prompt.", false)
|
||||
.option(
|
||||
"-c, --cwd <cwd>",
|
||||
"the working directory. defaults to the current directory.",
|
||||
process.cwd()
|
||||
)
|
||||
.option("-s, --silent", "mute output.", false)
|
||||
.action(async (positionalPreset, opts) => {
|
||||
try {
|
||||
const options = applyOptionsSchema.parse({
|
||||
...opts,
|
||||
cwd: path.resolve(opts.cwd),
|
||||
positionalPreset,
|
||||
})
|
||||
|
||||
const preset = resolveApplyPreset(options)
|
||||
|
||||
const preflight = await preFlightApply(options)
|
||||
|
||||
if (preflight.errors[ERRORS.MISSING_DIR_OR_EMPTY_PROJECT]) {
|
||||
logger.break()
|
||||
logger.error(
|
||||
`The ${highlighter.info(
|
||||
"apply"
|
||||
)} command only works in an existing project.`
|
||||
)
|
||||
logger.error(`Run ${highlighter.info(getInitCommand(preset))} first.`)
|
||||
logger.break()
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (preflight.errors[ERRORS.MISSING_CONFIG]) {
|
||||
logger.break()
|
||||
logger.error(
|
||||
`No ${highlighter.info("components.json")} found at ${highlighter.info(
|
||||
options.cwd
|
||||
)}.`
|
||||
)
|
||||
logger.error(`Run ${highlighter.info(getInitCommand(preset))} first.`)
|
||||
logger.break()
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const existingConfig = preflight.config
|
||||
if (!existingConfig) {
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const rtl = existingConfig.rtl ?? false
|
||||
const template = await resolveApplyTemplate(options.cwd)
|
||||
|
||||
if (!preset) {
|
||||
const createUrl = resolveCreateUrl({
|
||||
command: "init",
|
||||
template,
|
||||
base: getBase(existingConfig.style),
|
||||
rtl,
|
||||
})
|
||||
|
||||
await promptToOpenPresetBuilder({
|
||||
createUrl,
|
||||
followUp: `Then run ${highlighter.info(
|
||||
"shadcn apply --preset <preset>"
|
||||
)} with the preset code or preset URL from ui.shadcn.com.`,
|
||||
prompt: !options.yes,
|
||||
})
|
||||
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
validatePreset(preset)
|
||||
|
||||
const reinstallComponents = await getProjectComponents(options.cwd)
|
||||
|
||||
if (!options.yes) {
|
||||
logger.break()
|
||||
logger.warn(
|
||||
highlighter.warn(
|
||||
`Applying a new preset will overwrite existing UI components, fonts, and CSS variables.`
|
||||
)
|
||||
)
|
||||
logger.warn(
|
||||
`Commit or stash your changes before continuing so you can easily go back.`
|
||||
)
|
||||
logger.break()
|
||||
logger.log(" The following components will be re-installed:")
|
||||
if (reinstallComponents.length) {
|
||||
for (let i = 0; i < reinstallComponents.length; i += 8) {
|
||||
logger.log(` - ${reinstallComponents.slice(i, i + 8).join(", ")}`)
|
||||
}
|
||||
} else {
|
||||
logger.log(" - No installed UI components were detected.")
|
||||
}
|
||||
logger.break()
|
||||
|
||||
const { proceed } = await prompts({
|
||||
type: "confirm",
|
||||
name: "proceed",
|
||||
message: "Would you like to continue?",
|
||||
initial: false,
|
||||
})
|
||||
|
||||
if (!proceed) {
|
||||
logger.break()
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
await loadEnvFiles(options.cwd)
|
||||
|
||||
const currentBase = getBase(existingConfig.style)
|
||||
const initUrl = resolveApplyInitUrl(preset, currentBase, {
|
||||
template,
|
||||
rtl,
|
||||
})
|
||||
|
||||
await withFileBackup(
|
||||
path.resolve(options.cwd, "components.json"),
|
||||
async () => {
|
||||
const {
|
||||
registryBaseConfig,
|
||||
installStyleIndex,
|
||||
url: cleanUrl,
|
||||
} = await resolveRegistryBaseConfig(initUrl, options.cwd, {
|
||||
registries: existingConfig.registries as
|
||||
| z.infer<typeof registryConfigSchema>
|
||||
| undefined,
|
||||
})
|
||||
|
||||
await runInit({
|
||||
cwd: options.cwd,
|
||||
yes: true,
|
||||
force: false,
|
||||
reinstall: true,
|
||||
defaults: false,
|
||||
silent: options.silent,
|
||||
isNewProject: false,
|
||||
cssVariables: true,
|
||||
installStyleIndex,
|
||||
registryBaseConfig,
|
||||
existingConfig,
|
||||
components: [cleanUrl, ...reinstallComponents],
|
||||
})
|
||||
},
|
||||
{
|
||||
onBackupFailure: () => {
|
||||
logger.error(
|
||||
`Could not back up ${highlighter.info(
|
||||
"components.json"
|
||||
)}. Aborting.`
|
||||
)
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
logger.break()
|
||||
logger.log("Preset applied successfully.")
|
||||
logger.break()
|
||||
} catch (error) {
|
||||
logger.break()
|
||||
handleError(error)
|
||||
} finally {
|
||||
clearRegistryContext()
|
||||
}
|
||||
})
|
||||
|
||||
function resolveApplyPreset(options: z.infer<typeof applyOptionsSchema>) {
|
||||
const positionalPreset = options.positionalPreset?.trim()
|
||||
const flagPreset = options.preset?.trim()
|
||||
|
||||
if (positionalPreset && flagPreset && positionalPreset !== flagPreset) {
|
||||
logger.error(
|
||||
`Received two different preset values. Use either the positional preset or ${highlighter.info(
|
||||
"--preset"
|
||||
)}, or pass the same value to both.`
|
||||
)
|
||||
logger.break()
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
return flagPreset ?? positionalPreset
|
||||
}
|
||||
|
||||
function validatePreset(preset: string) {
|
||||
if (isUrl(preset) || isPresetCode(preset)) {
|
||||
return
|
||||
}
|
||||
|
||||
const knownPresetNames = Object.keys(DEFAULT_PRESETS)
|
||||
|
||||
if (!knownPresetNames.includes(preset)) {
|
||||
logger.error(
|
||||
`Invalid preset: ${highlighter.info(
|
||||
preset
|
||||
)}.\nUse one of the available presets: ${knownPresetNames.join(", ")} \nor build your own at ${highlighter.info(`${SHADCN_URL}/create`)}`
|
||||
)
|
||||
logger.break()
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveApplyTemplate(cwd: string) {
|
||||
const projectInfo = await getProjectInfo(cwd)
|
||||
return getTemplateForFramework(projectInfo?.framework.name)
|
||||
}
|
||||
|
||||
export function resolveApplyInitUrl(
|
||||
preset: string,
|
||||
currentBase: "radix" | "base",
|
||||
options: { template?: string; rtl?: boolean } = {}
|
||||
) {
|
||||
if (isUrl(preset)) {
|
||||
const url = new URL(preset)
|
||||
|
||||
if (url.pathname === "/init" && preset.startsWith(SHADCN_URL)) {
|
||||
url.searchParams.set("track", "1")
|
||||
}
|
||||
|
||||
url.searchParams.set("base", currentBase)
|
||||
url.searchParams.set("rtl", String(options.rtl ?? false))
|
||||
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
if (isPresetCode(preset)) {
|
||||
const decoded = decodePreset(preset)
|
||||
if (!decoded) {
|
||||
logger.error(`Invalid preset code: ${highlighter.info(preset)}`)
|
||||
logger.break()
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
return resolveInitUrl(
|
||||
{
|
||||
...decoded,
|
||||
base: currentBase,
|
||||
rtl: options.rtl ?? false,
|
||||
},
|
||||
{ preset, template: options.template }
|
||||
)
|
||||
}
|
||||
|
||||
const resolvedPreset = DEFAULT_PRESETS[preset as keyof typeof DEFAULT_PRESETS]
|
||||
|
||||
return resolveInitUrl(
|
||||
{
|
||||
...resolvedPreset,
|
||||
base: currentBase,
|
||||
rtl: options.rtl ?? resolvedPreset.rtl,
|
||||
},
|
||||
{ template: options.template }
|
||||
)
|
||||
}
|
||||
|
||||
function quoteShellArg(value: string) {
|
||||
return /[^A-Za-z0-9_./:-]/.test(value) ? JSON.stringify(value) : value
|
||||
}
|
||||
|
||||
function getInitCommand(preset?: string) {
|
||||
if (!preset) {
|
||||
return "shadcn init"
|
||||
}
|
||||
|
||||
return `shadcn init --preset ${quoteShellArg(preset)}`
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
#!/usr/bin/env node
|
||||
import { add } from "@/src/commands/add"
|
||||
import { apply } from "@/src/commands/apply"
|
||||
import { build } from "@/src/commands/build"
|
||||
import { diff } from "@/src/commands/diff"
|
||||
import { docs } from "@/src/commands/docs"
|
||||
@@ -29,6 +30,7 @@ async function main() {
|
||||
|
||||
program
|
||||
.addCommand(init)
|
||||
.addCommand(apply)
|
||||
.addCommand(add)
|
||||
.addCommand(diff)
|
||||
.addCommand(docs)
|
||||
|
||||
70
packages/shadcn/src/preflights/preflight-apply.ts
Normal file
70
packages/shadcn/src/preflights/preflight-apply.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import path from "path"
|
||||
import { SHADCN_URL } from "@/src/registry/constants"
|
||||
import * as ERRORS from "@/src/utils/errors"
|
||||
import { getConfig } from "@/src/utils/get-config"
|
||||
import {
|
||||
formatMonorepoMessage,
|
||||
getMonorepoTargets,
|
||||
isMonorepoRoot,
|
||||
} from "@/src/utils/get-monorepo-info"
|
||||
import { highlighter } from "@/src/utils/highlighter"
|
||||
import { logger } from "@/src/utils/logger"
|
||||
import fs from "fs-extra"
|
||||
|
||||
export async function preFlightApply(options: { cwd: string }) {
|
||||
const errors: Record<string, boolean> = {}
|
||||
|
||||
if (
|
||||
!fs.existsSync(options.cwd) ||
|
||||
!fs.existsSync(path.resolve(options.cwd, "package.json"))
|
||||
) {
|
||||
errors[ERRORS.MISSING_DIR_OR_EMPTY_PROJECT] = true
|
||||
return {
|
||||
errors,
|
||||
config: null,
|
||||
}
|
||||
}
|
||||
|
||||
if (!fs.existsSync(path.resolve(options.cwd, "components.json"))) {
|
||||
if (await isMonorepoRoot(options.cwd)) {
|
||||
const targets = await getMonorepoTargets(options.cwd)
|
||||
if (targets.length > 0) {
|
||||
formatMonorepoMessage("apply --preset <preset>", targets, {
|
||||
cwdFlag: "-c",
|
||||
})
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
errors[ERRORS.MISSING_CONFIG] = true
|
||||
return {
|
||||
errors,
|
||||
config: null,
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const config = await getConfig(options.cwd)
|
||||
|
||||
return {
|
||||
errors,
|
||||
config: config!,
|
||||
}
|
||||
} catch {
|
||||
logger.break()
|
||||
logger.error(
|
||||
`An invalid ${highlighter.info(
|
||||
"components.json"
|
||||
)} file was found at ${highlighter.info(
|
||||
options.cwd
|
||||
)}.\nBefore you can apply a preset, you must create a valid ${highlighter.info(
|
||||
"components.json"
|
||||
)} file by running the ${highlighter.info("init")} command.`
|
||||
)
|
||||
logger.error(
|
||||
`Learn more at ${highlighter.info(`${SHADCN_URL}/docs/components-json`)}.`
|
||||
)
|
||||
logger.break()
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
@@ -130,6 +130,34 @@ export function resolveCreateUrl(
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
export async function promptToOpenPresetBuilder(options: {
|
||||
createUrl: string
|
||||
followUp: string
|
||||
prompt?: boolean
|
||||
}) {
|
||||
logger.break()
|
||||
logger.log(
|
||||
` Build your custom preset on ${highlighter.info(options.createUrl)}`
|
||||
)
|
||||
logger.log(` ${options.followUp}`)
|
||||
logger.break()
|
||||
|
||||
if (options.prompt === false) {
|
||||
return
|
||||
}
|
||||
|
||||
const { proceed } = await prompts({
|
||||
type: "confirm",
|
||||
name: "proceed",
|
||||
message: "Open in browser?",
|
||||
initial: true,
|
||||
})
|
||||
|
||||
if (proceed) {
|
||||
await open(options.createUrl)
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveInitUrl(
|
||||
preset: {
|
||||
base: string
|
||||
@@ -234,26 +262,13 @@ export async function promptForPreset(options: {
|
||||
base: options.base,
|
||||
...(options.template && { template: options.template }),
|
||||
})
|
||||
logger.break()
|
||||
logger.log(` Build your custom preset on ${highlighter.info(createUrl)}`)
|
||||
logger.log(
|
||||
` Then ${highlighter.info(
|
||||
await promptToOpenPresetBuilder({
|
||||
createUrl,
|
||||
followUp: `Then ${highlighter.info(
|
||||
"copy and run the command"
|
||||
)} from ui.shadcn.com.`
|
||||
)
|
||||
logger.break()
|
||||
|
||||
const { proceed } = await prompts({
|
||||
type: "confirm",
|
||||
name: "proceed",
|
||||
message: "Open in browser?",
|
||||
initial: true,
|
||||
)} from ui.shadcn.com.`,
|
||||
})
|
||||
|
||||
if (proceed) {
|
||||
await open(createUrl)
|
||||
}
|
||||
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
|
||||
70
packages/shadcn/src/utils/file-helper.test.ts
Normal file
70
packages/shadcn/src/utils/file-helper.test.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import fs from "fs-extra"
|
||||
import { afterEach, describe, expect, it, vi } from "vitest"
|
||||
|
||||
import { FILE_BACKUP_SUFFIX, withFileBackup } from "./file-helper"
|
||||
|
||||
const tempDirs: string[] = []
|
||||
|
||||
async function createTempFile() {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "shadcn-file-helper-"))
|
||||
tempDirs.push(dir)
|
||||
|
||||
const filePath = path.join(dir, "components.json")
|
||||
await fs.writeFile(filePath, '{"style":"before"}\n', "utf8")
|
||||
|
||||
return filePath
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(tempDirs.splice(0).map((dir) => fs.remove(dir)))
|
||||
})
|
||||
|
||||
describe("withFileBackup", () => {
|
||||
it("should restore the original file when the task throws", async () => {
|
||||
const filePath = await createTempFile()
|
||||
|
||||
await expect(
|
||||
withFileBackup(filePath, async () => {
|
||||
await fs.writeFile(filePath, '{"style":"after"}\n', "utf8")
|
||||
throw new Error("boom")
|
||||
})
|
||||
).rejects.toThrow("boom")
|
||||
|
||||
expect(await fs.readFile(filePath, "utf8")).toBe('{"style":"before"}\n')
|
||||
expect(await fs.pathExists(`${filePath}${FILE_BACKUP_SUFFIX}`)).toBe(false)
|
||||
})
|
||||
|
||||
it("should remove the backup after a successful task", async () => {
|
||||
const filePath = await createTempFile()
|
||||
|
||||
await withFileBackup(filePath, async () => {
|
||||
await fs.writeFile(filePath, '{"style":"after"}\n', "utf8")
|
||||
})
|
||||
|
||||
expect(await fs.readFile(filePath, "utf8")).toBe('{"style":"after"}\n')
|
||||
expect(await fs.pathExists(`${filePath}${FILE_BACKUP_SUFFIX}`)).toBe(false)
|
||||
})
|
||||
|
||||
it("should abort when backup creation fails", async () => {
|
||||
const filePath = await createTempFile()
|
||||
const consoleErrorSpy = vi
|
||||
.spyOn(console, "error")
|
||||
.mockImplementation(() => {})
|
||||
const renameSyncSpy = vi.spyOn(fs, "renameSync").mockImplementation(() => {
|
||||
throw new Error("boom")
|
||||
})
|
||||
|
||||
await expect(
|
||||
withFileBackup(filePath, async () => {
|
||||
await fs.writeFile(filePath, '{"style":"after"}\n', "utf8")
|
||||
})
|
||||
).rejects.toThrow(`Could not back up ${filePath}.`)
|
||||
|
||||
expect(await fs.readFile(filePath, "utf8")).toBe('{"style":"before"}\n')
|
||||
|
||||
renameSyncSpy.mockRestore()
|
||||
consoleErrorSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
@@ -2,6 +2,10 @@ import fsExtra from "fs-extra"
|
||||
|
||||
export const FILE_BACKUP_SUFFIX = ".bak"
|
||||
|
||||
type WithFileBackupOptions = {
|
||||
onBackupFailure?: (filePath: string) => void
|
||||
}
|
||||
|
||||
export function createFileBackup(filePath: string): string | null {
|
||||
if (!fsExtra.existsSync(filePath)) {
|
||||
return null
|
||||
@@ -45,8 +49,40 @@ export function deleteFileBackup(filePath: string): boolean {
|
||||
try {
|
||||
fsExtra.unlinkSync(backupPath)
|
||||
return true
|
||||
} catch (error) {
|
||||
} catch {
|
||||
// Best effort - don't log as this is just cleanup
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export async function withFileBackup<T>(
|
||||
filePath: string,
|
||||
task: () => Promise<T>,
|
||||
options: WithFileBackupOptions = {}
|
||||
) {
|
||||
if (!fsExtra.existsSync(filePath)) {
|
||||
return task()
|
||||
}
|
||||
|
||||
const backupPath = createFileBackup(filePath)
|
||||
|
||||
if (!backupPath) {
|
||||
options.onBackupFailure?.(filePath)
|
||||
throw new Error(`Could not back up ${filePath}.`)
|
||||
}
|
||||
|
||||
const restoreBackupOnExit = () => restoreFileBackup(filePath)
|
||||
|
||||
process.on("exit", restoreBackupOnExit)
|
||||
|
||||
try {
|
||||
const result = await task()
|
||||
process.removeListener("exit", restoreBackupOnExit)
|
||||
deleteFileBackup(filePath)
|
||||
return result
|
||||
} catch (error) {
|
||||
process.removeListener("exit", restoreBackupOnExit)
|
||||
restoreFileBackup(filePath)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,8 +100,13 @@ export async function getMonorepoTargets(cwd: string) {
|
||||
// Formats and logs the monorepo detection message.
|
||||
export function formatMonorepoMessage(
|
||||
command: string,
|
||||
targets: { name: string; hasConfig: boolean }[]
|
||||
targets: { name: string; hasConfig: boolean }[],
|
||||
options?: {
|
||||
cwdFlag?: string
|
||||
}
|
||||
) {
|
||||
const cwdFlag = options?.cwdFlag ?? "-c"
|
||||
|
||||
logger.break()
|
||||
logger.log(
|
||||
`It looks like you are running ${highlighter.info(
|
||||
@@ -110,13 +115,13 @@ export function formatMonorepoMessage(
|
||||
)
|
||||
logger.log(
|
||||
`To use shadcn in a specific workspace, use the ${highlighter.info(
|
||||
"-c"
|
||||
cwdFlag
|
||||
)} flag:`
|
||||
)
|
||||
logger.break()
|
||||
|
||||
for (const target of targets) {
|
||||
logger.log(` shadcn ${command} -c ${target.name}`)
|
||||
logger.log(` shadcn ${command} ${cwdFlag} ${target.name}`)
|
||||
}
|
||||
|
||||
logger.break()
|
||||
|
||||
408
packages/tests/src/tests/apply.test.ts
Normal file
408
packages/tests/src/tests/apply.test.ts
Normal file
@@ -0,0 +1,408 @@
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import fs from "fs-extra"
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
import {
|
||||
createFixtureTestDirectory,
|
||||
getRegistryUrl,
|
||||
npxShadcn,
|
||||
} from "../utils/helpers"
|
||||
|
||||
async function createInitializedProject() {
|
||||
const fixturePath = await createFixtureTestDirectory("next-app")
|
||||
await npxShadcn(fixturePath, ["init", "--defaults"])
|
||||
await npxShadcn(fixturePath, ["add", "button"])
|
||||
return fixturePath
|
||||
}
|
||||
|
||||
async function createInitializedRtlProject() {
|
||||
const fixturePath = await createFixtureTestDirectory("next-app")
|
||||
await npxShadcn(fixturePath, ["init", "--defaults", "--rtl"])
|
||||
await npxShadcn(fixturePath, ["add", "button"])
|
||||
return fixturePath
|
||||
}
|
||||
|
||||
async function createInitializedViteRtlProject() {
|
||||
const fixturePath = await createFixtureTestDirectory("vite-app")
|
||||
await npxShadcn(fixturePath, ["init", "--defaults", "--rtl"])
|
||||
await npxShadcn(
|
||||
fixturePath,
|
||||
["add", "breadcrumb", "pagination", "sidebar", "-y"],
|
||||
{
|
||||
timeout: 120000,
|
||||
}
|
||||
)
|
||||
return fixturePath
|
||||
}
|
||||
|
||||
async function createInitializedRadixProject() {
|
||||
const fixturePath = await createFixtureTestDirectory("next-app")
|
||||
await npxShadcn(fixturePath, ["init", "--preset", "a0", "--base", "radix"])
|
||||
await npxShadcn(fixturePath, ["add", "button"])
|
||||
return fixturePath
|
||||
}
|
||||
|
||||
describe("shadcn apply", () => {
|
||||
it("should apply a preset with --preset <code>", async () => {
|
||||
const fixturePath = await createInitializedProject()
|
||||
const result = await npxShadcn(fixturePath, [
|
||||
"apply",
|
||||
"--preset",
|
||||
"a0",
|
||||
"-y",
|
||||
])
|
||||
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout).toContain("Preset applied successfully")
|
||||
expect(
|
||||
await fs.pathExists(path.join(fixturePath, "components/ui/button.tsx"))
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it("should apply a preset with positional <code>", async () => {
|
||||
const fixturePath = await createInitializedProject()
|
||||
const result = await npxShadcn(fixturePath, ["apply", "a0", "-y"])
|
||||
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout).toContain("Preset applied successfully")
|
||||
})
|
||||
|
||||
it("should allow the same positional and flag preset values", async () => {
|
||||
const fixturePath = await createInitializedProject()
|
||||
const result = await npxShadcn(fixturePath, [
|
||||
"apply",
|
||||
"a0",
|
||||
"--preset",
|
||||
"a0",
|
||||
"-y",
|
||||
])
|
||||
|
||||
expect(result.exitCode).toBe(0)
|
||||
})
|
||||
|
||||
it("should reject different positional and flag preset values", async () => {
|
||||
const fixturePath = await createInitializedProject()
|
||||
const result = await npxShadcn(fixturePath, [
|
||||
"apply",
|
||||
"a0",
|
||||
"--preset",
|
||||
"b0",
|
||||
"-y",
|
||||
])
|
||||
|
||||
expect(result.exitCode).toBe(1)
|
||||
expect(result.stdout).toContain("Received two different preset values")
|
||||
})
|
||||
|
||||
it("should offer the preset builder when no preset is provided", async () => {
|
||||
const fixturePath = await createInitializedProject()
|
||||
const componentsJson = await fs.readJson(
|
||||
path.join(fixturePath, "components.json")
|
||||
)
|
||||
const createUrl = new URL(
|
||||
`${getRegistryUrl().replace(/\/r\/?$/, "")}/create`
|
||||
)
|
||||
createUrl.searchParams.set("command", "init")
|
||||
createUrl.searchParams.set("template", "next")
|
||||
createUrl.searchParams.set(
|
||||
"base",
|
||||
componentsJson.style.startsWith("base-") ? "base" : "radix"
|
||||
)
|
||||
const result = await npxShadcn(fixturePath, ["apply"], {
|
||||
input: "n\n",
|
||||
})
|
||||
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout).toContain("Build your custom preset on")
|
||||
expect(result.stdout).toContain(createUrl.toString())
|
||||
expect(result.stdout).toContain("shadcn apply --preset <preset>")
|
||||
})
|
||||
|
||||
it("should print the preset builder url without prompting when no preset is provided with -y", async () => {
|
||||
const fixturePath = await createInitializedProject()
|
||||
const result = await npxShadcn(fixturePath, ["apply", "-y"])
|
||||
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout).toContain("Build your custom preset on")
|
||||
expect(result.stdout).not.toContain("Open in browser?")
|
||||
})
|
||||
|
||||
it("should warn before applying and list detected components", async () => {
|
||||
const fixturePath = await createInitializedProject()
|
||||
const result = await npxShadcn(fixturePath, ["apply", "--preset", "a0"], {
|
||||
input: "y\n",
|
||||
})
|
||||
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout).toContain(
|
||||
"Applying a new preset will overwrite existing UI components, fonts, and CSS variables."
|
||||
)
|
||||
expect(result.stdout).toContain(
|
||||
"Commit or stash your changes before continuing so you can easily go back."
|
||||
)
|
||||
expect(result.stdout).toContain(
|
||||
"The following components will be re-installed:"
|
||||
)
|
||||
expect(result.stdout).toContain("button")
|
||||
})
|
||||
|
||||
it("should skip confirmation with -y", async () => {
|
||||
const fixturePath = await createInitializedProject()
|
||||
const result = await npxShadcn(fixturePath, [
|
||||
"apply",
|
||||
"--preset",
|
||||
"a0",
|
||||
"-y",
|
||||
])
|
||||
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout).not.toContain("Would you like to continue?")
|
||||
})
|
||||
|
||||
it("should suggest init when components.json is missing", async () => {
|
||||
const fixturePath = await createFixtureTestDirectory("next-app")
|
||||
const result = await npxShadcn(fixturePath, ["apply", "--preset", "a0"])
|
||||
|
||||
expect(result.exitCode).toBe(1)
|
||||
expect(result.stdout).toContain("components.json")
|
||||
expect(result.stdout).toContain("shadcn init --preset a0")
|
||||
})
|
||||
|
||||
it("should not show undefined in init guidance when components.json is missing and no preset is provided", async () => {
|
||||
const fixturePath = await createFixtureTestDirectory("next-app")
|
||||
const result = await npxShadcn(fixturePath, ["apply"])
|
||||
|
||||
expect(result.exitCode).toBe(1)
|
||||
expect(result.stdout).toContain("components.json")
|
||||
expect(result.stdout).toContain("shadcn init")
|
||||
expect(result.stdout).not.toContain("undefined")
|
||||
})
|
||||
|
||||
it("should fail on invalid components.json", async () => {
|
||||
const fixturePath = await createFixtureTestDirectory("next-app")
|
||||
await fs.writeFile(path.join(fixturePath, "components.json"), "{", "utf8")
|
||||
|
||||
const result = await npxShadcn(fixturePath, ["apply", "--preset", "a0"])
|
||||
|
||||
expect(result.exitCode).toBe(1)
|
||||
expect(result.stdout).toContain("An invalid components.json file was found")
|
||||
})
|
||||
|
||||
it("should restore components.json when applying a preset fails", async () => {
|
||||
const fixturePath = await createInitializedProject()
|
||||
const componentsJsonPath = path.join(fixturePath, "components.json")
|
||||
const original = await fs.readFile(componentsJsonPath, "utf8")
|
||||
|
||||
const result = await npxShadcn(fixturePath, [
|
||||
"apply",
|
||||
"--preset",
|
||||
`${getRegistryUrl()}/does-not-exist.json`,
|
||||
"-y",
|
||||
])
|
||||
|
||||
expect(result.exitCode).toBe(1)
|
||||
expect(await fs.readFile(componentsJsonPath, "utf8")).toBe(original)
|
||||
expect(await fs.pathExists(`${componentsJsonPath}.bak`)).toBe(false)
|
||||
})
|
||||
|
||||
it("should guide the user to a workspace when run from a monorepo root", async () => {
|
||||
const rootDir = path.join(
|
||||
os.tmpdir(),
|
||||
`shadcn-apply-monorepo-${process.pid}-${Date.now()}`
|
||||
)
|
||||
|
||||
await fs.ensureDir(path.join(rootDir, "apps/web"))
|
||||
await fs.writeJson(path.join(rootDir, "package.json"), {
|
||||
private: true,
|
||||
workspaces: ["apps/*"],
|
||||
})
|
||||
await fs.writeFile(
|
||||
path.join(rootDir, "pnpm-workspace.yaml"),
|
||||
'packages:\n - "apps/*"\n',
|
||||
"utf8"
|
||||
)
|
||||
await fs.writeJson(path.join(rootDir, "apps/web/package.json"), {
|
||||
name: "web",
|
||||
version: "0.0.0",
|
||||
})
|
||||
await fs.writeFile(
|
||||
path.join(rootDir, "apps/web/next.config.ts"),
|
||||
"",
|
||||
"utf8"
|
||||
)
|
||||
|
||||
const result = await npxShadcn(rootDir, ["apply", "--preset", "a0"])
|
||||
|
||||
expect(result.exitCode).toBe(1)
|
||||
expect(result.stdout).toContain("monorepo root")
|
||||
expect(result.stdout).toContain(
|
||||
"shadcn apply --preset <preset> -c apps/web"
|
||||
)
|
||||
|
||||
await fs.remove(rootDir)
|
||||
})
|
||||
|
||||
it("should update the project config and reinstall detected components", async () => {
|
||||
const fixturePath = await createInitializedProject()
|
||||
const result = await npxShadcn(fixturePath, [
|
||||
"apply",
|
||||
"--preset",
|
||||
"lyra",
|
||||
"-y",
|
||||
])
|
||||
|
||||
expect(result.exitCode).toBe(0)
|
||||
|
||||
const componentsJson = await fs.readJson(
|
||||
path.join(fixturePath, "components.json")
|
||||
)
|
||||
expect(componentsJson.style).toBe("base-lyra")
|
||||
expect(componentsJson.iconLibrary).toBe("phosphor")
|
||||
expect(
|
||||
await fs.pathExists(path.join(fixturePath, "components/ui/button.tsx"))
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it("should preserve rtl when applying a named preset", async () => {
|
||||
const fixturePath = await createInitializedRtlProject()
|
||||
const result = await npxShadcn(fixturePath, [
|
||||
"apply",
|
||||
"--preset",
|
||||
"lyra",
|
||||
"-y",
|
||||
])
|
||||
|
||||
expect(result.exitCode).toBe(0)
|
||||
|
||||
const componentsJson = await fs.readJson(
|
||||
path.join(fixturePath, "components.json")
|
||||
)
|
||||
expect(componentsJson.rtl).toBe(true)
|
||||
})
|
||||
|
||||
it("should preserve the current base for raw preset urls", async () => {
|
||||
const fixturePath = await createInitializedRadixProject()
|
||||
const presetUrl = `${getRegistryUrl().replace(/\/r\/?$/, "")}/init?base=base&style=lyra&baseColor=neutral&theme=neutral&iconLibrary=phosphor&font=manrope&rtl=false&menuAccent=subtle&menuColor=default&radius=default`
|
||||
|
||||
const result = await npxShadcn(fixturePath, [
|
||||
"apply",
|
||||
"--preset",
|
||||
presetUrl,
|
||||
"-y",
|
||||
])
|
||||
|
||||
expect(result.exitCode).toBe(0)
|
||||
|
||||
const componentsJson = await fs.readJson(
|
||||
path.join(fixturePath, "components.json")
|
||||
)
|
||||
expect(componentsJson.style).toBe("radix-lyra")
|
||||
})
|
||||
|
||||
it("should preserve rtl for raw preset urls", async () => {
|
||||
const fixturePath = await createInitializedRtlProject()
|
||||
const presetUrl = `${getRegistryUrl().replace(/\/r\/?$/, "")}/init?base=base&style=lyra&baseColor=neutral&theme=neutral&iconLibrary=phosphor&font=manrope&rtl=false&menuAccent=subtle&menuColor=default&radius=default`
|
||||
|
||||
const result = await npxShadcn(fixturePath, [
|
||||
"apply",
|
||||
"--preset",
|
||||
presetUrl,
|
||||
"-y",
|
||||
])
|
||||
|
||||
expect(result.exitCode).toBe(0)
|
||||
|
||||
const componentsJson = await fs.readJson(
|
||||
path.join(fixturePath, "components.json")
|
||||
)
|
||||
expect(componentsJson.rtl).toBe(true)
|
||||
expect(componentsJson.style).toBe("base-lyra")
|
||||
})
|
||||
|
||||
it("should preserve non-preset config when applying a preset", async () => {
|
||||
const fixturePath = await createInitializedProject()
|
||||
const componentsJsonPath = path.join(fixturePath, "components.json")
|
||||
const componentsJson = await fs.readJson(componentsJsonPath)
|
||||
|
||||
componentsJson.tailwind.prefix = "tw-"
|
||||
await fs.writeJson(componentsJsonPath, componentsJson, { spaces: 2 })
|
||||
|
||||
const result = await npxShadcn(fixturePath, [
|
||||
"apply",
|
||||
"--preset",
|
||||
"lyra",
|
||||
"-y",
|
||||
])
|
||||
|
||||
expect(result.exitCode).toBe(0)
|
||||
|
||||
const updatedConfig = await fs.readJson(componentsJsonPath)
|
||||
expect(updatedConfig.tailwind.prefix).toBe("tw-")
|
||||
expect(updatedConfig.style).toBe("base-lyra")
|
||||
expect(updatedConfig.iconLibrary).toBe("phosphor")
|
||||
})
|
||||
|
||||
it("should keep vite component output rtl-aware when applying a new preset", async () => {
|
||||
const fixturePath = await createInitializedViteRtlProject()
|
||||
const componentsJsonPath = path.join(fixturePath, "components.json")
|
||||
const breadcrumbPath = path.join(
|
||||
fixturePath,
|
||||
"src/components/ui/breadcrumb.tsx"
|
||||
)
|
||||
const paginationPath = path.join(
|
||||
fixturePath,
|
||||
"src/components/ui/pagination.tsx"
|
||||
)
|
||||
const sidebarPath = path.join(fixturePath, "src/components/ui/sidebar.tsx")
|
||||
|
||||
const initialConfig = await fs.readJson(componentsJsonPath)
|
||||
const initialBreadcrumb = await fs.readFile(breadcrumbPath, "utf8")
|
||||
const initialPagination = await fs.readFile(paginationPath, "utf8")
|
||||
const initialSidebar = await fs.readFile(sidebarPath, "utf8")
|
||||
|
||||
expect(initialConfig.style).toBe("base-nova")
|
||||
expect(initialConfig.iconLibrary).toBe("lucide")
|
||||
expect(initialConfig.rtl).toBe(true)
|
||||
expect(initialBreadcrumb).toContain("rtl:rotate-180")
|
||||
expect(initialPagination).toContain("ps-1.5!")
|
||||
expect(initialPagination).toContain("pe-1.5!")
|
||||
expect(initialPagination).toContain("rtl:rotate-180")
|
||||
expect(initialSidebar).toContain("start-")
|
||||
expect(initialSidebar).toContain("end-")
|
||||
expect(initialSidebar).toContain("ms-")
|
||||
expect(initialSidebar).toContain("pe-")
|
||||
expect(initialSidebar).toContain("rtl:")
|
||||
|
||||
const result = await npxShadcn(
|
||||
fixturePath,
|
||||
["apply", "--preset", "lyra", "-y"],
|
||||
{
|
||||
timeout: 120000,
|
||||
}
|
||||
)
|
||||
|
||||
expect(result.exitCode).toBe(0)
|
||||
|
||||
const updatedConfig = await fs.readJson(componentsJsonPath)
|
||||
const updatedBreadcrumb = await fs.readFile(breadcrumbPath, "utf8")
|
||||
const updatedPagination = await fs.readFile(paginationPath, "utf8")
|
||||
const updatedSidebar = await fs.readFile(sidebarPath, "utf8")
|
||||
|
||||
expect(updatedConfig.style).toBe("base-lyra")
|
||||
expect(updatedConfig.iconLibrary).toBe("phosphor")
|
||||
expect(updatedConfig.rtl).toBe(true)
|
||||
expect(updatedBreadcrumb).toContain("rtl:rotate-180")
|
||||
expect(updatedPagination).toContain("ps-1.5!")
|
||||
expect(updatedPagination).toContain("pe-1.5!")
|
||||
expect(updatedPagination).toContain("rtl:rotate-180")
|
||||
expect(updatedSidebar).toContain("start-")
|
||||
expect(updatedSidebar).toContain("end-")
|
||||
expect(updatedSidebar).toContain("ms-")
|
||||
expect(updatedSidebar).toContain("pe-")
|
||||
expect(updatedSidebar).toContain("rtl:")
|
||||
expect(updatedBreadcrumb).not.toBe(initialBreadcrumb)
|
||||
expect(updatedPagination).not.toBe(initialPagination)
|
||||
expect(updatedSidebar).not.toBe(initialSidebar)
|
||||
})
|
||||
})
|
||||
@@ -71,9 +71,11 @@ export async function npxShadcn(
|
||||
args: string[],
|
||||
{
|
||||
debug = false,
|
||||
input,
|
||||
timeout,
|
||||
}: {
|
||||
debug?: boolean
|
||||
input?: string
|
||||
timeout?: number
|
||||
} = {}
|
||||
) {
|
||||
@@ -82,6 +84,7 @@ export async function npxShadcn(
|
||||
REGISTRY_URL: getRegistryUrl(),
|
||||
SHADCN_TEMPLATE_DIR: TEMPLATES_DIR,
|
||||
},
|
||||
input,
|
||||
timeout,
|
||||
})
|
||||
|
||||
|
||||
2
pnpm-lock.yaml
generated
2
pnpm-lock.yaml
generated
@@ -284,7 +284,7 @@ importers:
|
||||
specifier: ^0.0.1
|
||||
version: 0.0.1
|
||||
shadcn:
|
||||
specifier: 4.1.2
|
||||
specifier: 4.2.0
|
||||
version: link:../../packages/shadcn
|
||||
shiki:
|
||||
specifier: ^1.10.1
|
||||
|
||||
@@ -77,7 +77,7 @@ These rules are **always enforced**. Each links to a file with Incorrect/Correct
|
||||
|
||||
### CLI
|
||||
|
||||
- **Never decode or fetch preset codes manually.** Pass them directly to `npx shadcn@latest init --preset <code>`.
|
||||
- **Never decode or fetch preset codes manually.** Pass them directly to `npx shadcn@latest apply --preset <code>` for existing projects, or `npx shadcn@latest init --preset <code>` when initializing.
|
||||
|
||||
## Key Patterns
|
||||
|
||||
@@ -173,11 +173,11 @@ npx shadcn@latest docs button dialog select
|
||||
6. **Fix imports in third-party components** — After adding components from community registries (e.g. `@bundui`, `@magicui`), check the added non-UI files for hardcoded import paths like `@/components/ui/...`. These won't match the project's actual aliases. Use `npx shadcn@latest info` to get the correct `ui` alias (e.g. `@workspace/ui/components`) and rewrite the imports accordingly. The CLI rewrites imports for its own UI files, but third-party registry components may use default paths that don't match the project.
|
||||
7. **Review added components** — After adding a component or block from any registry, **always read the added files and verify they are correct**. Check for missing sub-components (e.g. `SelectItem` without `SelectGroup`), missing imports, incorrect composition, or violations of the [Critical Rules](#critical-rules). Also replace any icon imports with the project's `iconLibrary` from the project context (e.g. if the registry item uses `lucide-react` but the project uses `hugeicons`, swap the imports and icon names accordingly). Fix all issues before moving on.
|
||||
8. **Registry must be explicit** — When the user asks to add a block or component, **do not guess the registry**. If no registry is specified (e.g. user says "add a login block" without specifying `@shadcn`, `@tailark`, etc.), ask which registry to use. Never default to a registry on behalf of the user.
|
||||
9. **Switching presets** — Ask the user first: **reinstall**, **merge**, or **skip**?
|
||||
- **Reinstall**: `npx shadcn@latest init --preset <code> --force --reinstall`. Overwrites all components.
|
||||
9. **Switching presets** — Ask the user first: **overwrite**, **merge**, or **skip**?
|
||||
- **Overwrite**: `npx shadcn@latest apply --preset <code>`. Overwrites detected components, fonts, and CSS variables.
|
||||
- **Merge**: `npx shadcn@latest init --preset <code> --force --no-reinstall`, then run `npx shadcn@latest info` to list installed components, then for each installed component use `--dry-run` and `--diff` to [smart merge](#updating-components) it individually.
|
||||
- **Skip**: `npx shadcn@latest init --preset <code> --force --no-reinstall`. Only updates config and CSS, leaves components as-is.
|
||||
- **Important**: Always run preset commands inside the user's project directory. The CLI automatically preserves the current base (`base` vs `radix`) from `components.json`. If you must use a scratch/temp directory (e.g. for `--dry-run` comparisons), pass `--base <current-base>` explicitly — preset codes do not encode the base.
|
||||
- **Important**: Always run preset commands inside the user's project directory. `apply` only works in an existing project with a `components.json` file. The CLI automatically preserves the current base (`base` vs `radix`) from `components.json`. If you must use a scratch/temp directory (e.g. for `--dry-run` comparisons), pass `--base <current-base>` explicitly — preset codes do not encode the base.
|
||||
|
||||
## Updating Components
|
||||
|
||||
@@ -204,7 +204,11 @@ npx shadcn@latest init --name my-app --preset base-nova --template next --monore
|
||||
|
||||
# Initialize existing project.
|
||||
npx shadcn@latest init --preset base-nova
|
||||
npx shadcn@latest init --defaults # shortcut: --template=next --preset=base-nova
|
||||
npx shadcn@latest init --defaults # shortcut: --template=next --preset=nova (base style implied)
|
||||
|
||||
# Apply a preset to an existing project.
|
||||
npx shadcn@latest apply --preset a2r6bw
|
||||
npx shadcn@latest apply a2r6bw
|
||||
|
||||
# Add components.
|
||||
npx shadcn@latest add button card dialog
|
||||
@@ -227,9 +231,9 @@ npx shadcn@latest docs button dialog select
|
||||
npx shadcn@latest view @shadcn/button
|
||||
```
|
||||
|
||||
**Named presets:** `base-nova`, `radix-nova`
|
||||
**Named presets:** `nova`, `vega`, `maia`, `lyra`, `mira`, `luma`
|
||||
**Templates:** `next`, `vite`, `start`, `react-router`, `astro` (all support `--monorepo`) and `laravel` (not supported for monorepo)
|
||||
**Preset codes:** Base62 strings starting with `a` (e.g. `a2r6bw`), from [ui.shadcn.com](https://ui.shadcn.com).
|
||||
**Preset codes:** Version-prefixed base62 strings (e.g. `a2r6bw` or `b0`), from [ui.shadcn.com](https://ui.shadcn.com).
|
||||
|
||||
## Detailed References
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ Configuration is read from `components.json`.
|
||||
|
||||
## Contents
|
||||
|
||||
- Commands: init, add (dry-run, smart merge), search, view, docs, info, build
|
||||
- Commands: init, apply, add (dry-run, smart merge), search, view, docs, info, build
|
||||
- Templates: next, vite, start, react-router, astro
|
||||
- Presets: named, code, URL formats and fields
|
||||
- Switching presets
|
||||
@@ -42,6 +42,24 @@ Initializes shadcn/ui in an existing project or creates a new project (when `--n
|
||||
|
||||
`npx shadcn@latest create` is an alias for `npx shadcn@latest init`.
|
||||
|
||||
### `apply` — Apply a preset to an existing project
|
||||
|
||||
```bash
|
||||
npx shadcn@latest apply [preset] [options]
|
||||
```
|
||||
|
||||
Applies a preset to an existing project, overwriting preset-driven config, fonts, CSS variables, and detected UI components.
|
||||
|
||||
| Flag | Short | Description | Default |
|
||||
| ------------------- | ----- | ------------------------------------------ | ------- |
|
||||
| `--preset <preset>` | — | Preset configuration (named, code, or URL) | — |
|
||||
| `--yes` | `-y` | Skip confirmation prompt | `false` |
|
||||
| `--cwd <cwd>` | `-c` | Working directory | current |
|
||||
| `--silent` | `-s` | Mute output | `false` |
|
||||
|
||||
`[preset]` is a shorthand for `--preset <preset>`. If both are provided, they must match.
|
||||
If no preset is provided, the CLI offers to open the custom preset builder on `ui.shadcn.com/create`.
|
||||
|
||||
### `add` — Add components
|
||||
|
||||
> **IMPORTANT:** To compare local components against upstream or to preview changes, ALWAYS use `npx shadcn@latest add <component> --dry-run`, `--diff`, or `--view`. NEVER fetch raw files from GitHub or other sources manually. The CLI handles registry resolution, file paths, and CSS diffing automatically.
|
||||
@@ -240,18 +258,19 @@ All templates support monorepo scaffolding via the `--monorepo` flag. When passe
|
||||
|
||||
Three ways to specify a preset via `--preset`:
|
||||
|
||||
1. **Named:** `--preset base-nova` or `--preset radix-nova`
|
||||
2. **Code:** `--preset a2r6bw` (base62 string, starts with lowercase `a`)
|
||||
1. **Named:** `--preset nova` or `--preset lyra`
|
||||
2. **Code:** `--preset a2r6bw` (version-prefixed base62 string, e.g. `a2r6bw` or `b0`)
|
||||
3. **URL:** `--preset "https://ui.shadcn.com/init?base=radix&style=nova&..."`
|
||||
|
||||
> **IMPORTANT:** Never try to decode, fetch, or resolve preset codes manually. Preset codes are opaque — pass them directly to `npx shadcn@latest init --preset <code>` and let the CLI handle resolution.
|
||||
> Use `npx shadcn@latest apply --preset <code>` when overwriting an existing project's preset.
|
||||
|
||||
## Switching Presets
|
||||
|
||||
Ask the user first: **reinstall**, **merge**, or **skip** existing components?
|
||||
Ask the user first: **overwrite**, **merge**, or **skip** existing components?
|
||||
|
||||
- **Re-install** → `npx shadcn@latest init --preset <code> --force --reinstall`. Overwrites all component files with the new preset styles. Use when the user hasn't customized components.
|
||||
- **Overwrite / Re-install** → `npx shadcn@latest apply --preset <code>`. Overwrites all detected component files with the new preset styles. Use when the user hasn't customized components.
|
||||
- **Merge** → `npx shadcn@latest init --preset <code> --force --no-reinstall`, then run `npx shadcn@latest info` to get the list of installed components and use the [smart merge workflow](./SKILL.md#updating-components) to update them one by one, preserving local changes. Use when the user has customized components.
|
||||
- **Skip** → `npx shadcn@latest init --preset <code> --force --no-reinstall`. Only updates config and CSS variables, leaves existing components as-is.
|
||||
|
||||
Always run preset commands inside the user's project directory. The CLI automatically preserves the current base (`base` vs `radix`) from `components.json`. If you must use a scratch/temp directory (e.g. for `--dry-run` comparisons), pass `--base <current-base>` explicitly — preset codes do not encode the base.
|
||||
Always run preset commands inside the user's project directory. `apply` only works in an existing project with a `components.json` file. The CLI automatically preserves the current base (`base` vs `radix`) from `components.json`. If you must use a scratch/temp directory (e.g. for `--dry-run` comparisons), pass `--base <current-base>` explicitly — preset codes do not encode the base.
|
||||
|
||||
@@ -65,14 +65,19 @@ import { ThemeProvider } from "next-themes"
|
||||
|
||||
```bash
|
||||
# Apply a preset code from ui.shadcn.com.
|
||||
npx shadcn@latest init --preset a2r6bw --force
|
||||
npx shadcn@latest apply --preset a2r6bw
|
||||
|
||||
# Switch to a named preset.
|
||||
npx shadcn@latest init --preset radix-nova --force
|
||||
npx shadcn@latest init --reinstall # update existing components to match
|
||||
# Positional shorthand also works.
|
||||
npx shadcn@latest apply a2r6bw
|
||||
|
||||
# Switch to a named preset and overwrite existing components.
|
||||
npx shadcn@latest apply --preset nova
|
||||
|
||||
# Preserve existing components instead.
|
||||
npx shadcn@latest init --preset nova --force --no-reinstall
|
||||
|
||||
# Use a custom theme URL.
|
||||
npx shadcn@latest init --preset "https://ui.shadcn.com/init?base=radix&style=nova&theme=blue&..." --force
|
||||
npx shadcn@latest apply --preset "https://ui.shadcn.com/init?base=radix&style=nova&theme=blue&..."
|
||||
```
|
||||
|
||||
Or edit CSS variables directly in `globals.css`.
|
||||
@@ -142,13 +147,15 @@ Prefer these approaches in order:
|
||||
### 1. Built-in variants
|
||||
|
||||
```tsx
|
||||
<Button variant="outline" size="sm">Click</Button>
|
||||
<Button variant="outline" size="sm">
|
||||
Click
|
||||
</Button>
|
||||
```
|
||||
|
||||
### 2. Tailwind classes via `className`
|
||||
|
||||
```tsx
|
||||
<Card className="max-w-md mx-auto">...</Card>
|
||||
<Card className="mx-auto max-w-md">...</Card>
|
||||
```
|
||||
|
||||
### 3. Add a new variant
|
||||
|
||||
Reference in New Issue
Block a user