snapshot of https://github.com/shadcn-ui/ui.git (main, 2026-09-15; blobs > 131072B omitted)
This commit is contained in:
28
apps/v4/hooks/use-colors.ts
Normal file
28
apps/v4/hooks/use-colors.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { useAtom } from "jotai"
|
||||
import { atomWithStorage } from "jotai/utils"
|
||||
|
||||
import { type ColorFormat } from "@/lib/colors"
|
||||
import { useMounted } from "@/hooks/use-mounted"
|
||||
|
||||
type Config = {
|
||||
format: ColorFormat
|
||||
lastCopied: string
|
||||
}
|
||||
|
||||
const colorsAtom = atomWithStorage<Config>("colors", {
|
||||
format: "hsl",
|
||||
lastCopied: "",
|
||||
})
|
||||
|
||||
export function useColors() {
|
||||
const [colors, setColors] = useAtom(colorsAtom)
|
||||
const mounted = useMounted()
|
||||
|
||||
return {
|
||||
isLoading: !mounted,
|
||||
format: colors.format,
|
||||
lastCopied: colors.lastCopied,
|
||||
setFormat: (format: ColorFormat) => setColors({ ...colors, format }),
|
||||
setLastCopied: (lastCopied: string) => setColors({ ...colors, lastCopied }),
|
||||
}
|
||||
}
|
||||
18
apps/v4/hooks/use-config.ts
Normal file
18
apps/v4/hooks/use-config.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { useAtom } from "jotai"
|
||||
import { atomWithStorage } from "jotai/utils"
|
||||
|
||||
type Config = {
|
||||
style: "new-york-v4"
|
||||
packageManager: "npm" | "yarn" | "pnpm" | "bun"
|
||||
installationType: "cli" | "manual"
|
||||
}
|
||||
|
||||
const configAtom = atomWithStorage<Config>("config", {
|
||||
style: "new-york-v4",
|
||||
packageManager: "pnpm",
|
||||
installationType: "cli",
|
||||
})
|
||||
|
||||
export function useConfig() {
|
||||
return useAtom(configAtom)
|
||||
}
|
||||
80
apps/v4/hooks/use-copy-to-clipboard.ts
Normal file
80
apps/v4/hooks/use-copy-to-clipboard.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
|
||||
function legacyCopyToClipboard(value: string) {
|
||||
const textArea = document.createElement("textarea")
|
||||
textArea.value = value
|
||||
textArea.setAttribute("readonly", "")
|
||||
textArea.style.position = "fixed"
|
||||
textArea.style.opacity = "0"
|
||||
textArea.style.pointerEvents = "none"
|
||||
|
||||
document.body.appendChild(textArea)
|
||||
textArea.focus()
|
||||
textArea.select()
|
||||
textArea.setSelectionRange(0, value.length)
|
||||
|
||||
let hasCopied = false
|
||||
try {
|
||||
hasCopied = document.execCommand("copy")
|
||||
} catch {
|
||||
hasCopied = false
|
||||
}
|
||||
|
||||
document.body.removeChild(textArea)
|
||||
return hasCopied
|
||||
}
|
||||
|
||||
export function useCopyToClipboard({
|
||||
timeout = 2000,
|
||||
onCopy,
|
||||
}: {
|
||||
timeout?: number
|
||||
onCopy?: () => void
|
||||
} = {}) {
|
||||
const [isCopied, setIsCopied] = React.useState(false)
|
||||
|
||||
const copyToClipboard = async (value: string) => {
|
||||
if (typeof window === "undefined") {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!value) {
|
||||
return false
|
||||
}
|
||||
|
||||
let hasCopied = false
|
||||
|
||||
if (navigator.clipboard?.writeText) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value)
|
||||
hasCopied = true
|
||||
} catch {
|
||||
hasCopied = legacyCopyToClipboard(value)
|
||||
}
|
||||
} else {
|
||||
hasCopied = legacyCopyToClipboard(value)
|
||||
}
|
||||
|
||||
if (!hasCopied) {
|
||||
return false
|
||||
}
|
||||
|
||||
setIsCopied(true)
|
||||
|
||||
if (onCopy) {
|
||||
onCopy()
|
||||
}
|
||||
|
||||
if (timeout !== 0) {
|
||||
setTimeout(() => {
|
||||
setIsCopied(false)
|
||||
}, timeout)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
return { isCopied, copyToClipboard }
|
||||
}
|
||||
19
apps/v4/hooks/use-delayed-status.ts
Normal file
19
apps/v4/hooks/use-delayed-status.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import * as React from "react"
|
||||
import type { ChatStatus } from "ai"
|
||||
|
||||
export function useDelayedStatus(status: ChatStatus, delay: number) {
|
||||
const [delayedStatus, setDelayedStatus] = React.useState(status)
|
||||
|
||||
React.useEffect(() => {
|
||||
const timeout = window.setTimeout(
|
||||
() => {
|
||||
setDelayedStatus(status)
|
||||
},
|
||||
status === "submitted" ? delay : 0
|
||||
)
|
||||
|
||||
return () => window.clearTimeout(timeout)
|
||||
}, [delay, status])
|
||||
|
||||
return delayedStatus
|
||||
}
|
||||
163
apps/v4/hooks/use-layout.tsx
Normal file
163
apps/v4/hooks/use-layout.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
|
||||
type Layout = "fixed" | "full"
|
||||
|
||||
interface LayoutProviderProps {
|
||||
children: React.ReactNode
|
||||
defaultLayout?: Layout
|
||||
forcedLayout?: Layout
|
||||
storageKey?: string
|
||||
attribute?: string | string[]
|
||||
value?: Record<string, string>
|
||||
}
|
||||
|
||||
interface LayoutProviderState {
|
||||
layout: Layout
|
||||
setLayout: (layout: Layout | ((prev: Layout) => Layout)) => void
|
||||
forcedLayout?: Layout
|
||||
}
|
||||
|
||||
const isServer = typeof window === "undefined"
|
||||
const LayoutContext = React.createContext<LayoutProviderState | undefined>(
|
||||
undefined
|
||||
)
|
||||
|
||||
const saveToLS = (storageKey: string, value: string) => {
|
||||
try {
|
||||
localStorage.setItem(storageKey, value)
|
||||
} catch {
|
||||
// Unsupported
|
||||
}
|
||||
}
|
||||
|
||||
const useLayout = () => {
|
||||
const context = React.useContext(LayoutContext)
|
||||
if (context === undefined) {
|
||||
throw new Error("useLayout must be used within a LayoutProvider")
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
const Layout = ({
|
||||
forcedLayout,
|
||||
storageKey = "layout",
|
||||
defaultLayout = "full",
|
||||
attribute = "class",
|
||||
value,
|
||||
children,
|
||||
}: LayoutProviderProps) => {
|
||||
const [layout, setLayoutState] = React.useState<Layout>(() => {
|
||||
if (isServer) return defaultLayout
|
||||
try {
|
||||
const saved = localStorage.getItem(storageKey)
|
||||
if (saved === "fixed" || saved === "full") {
|
||||
return saved
|
||||
}
|
||||
return defaultLayout
|
||||
} catch {
|
||||
return defaultLayout
|
||||
}
|
||||
})
|
||||
|
||||
const attrs = React.useMemo(
|
||||
() => (!value ? ["layout-fixed", "layout-full"] : Object.values(value)),
|
||||
[value]
|
||||
)
|
||||
|
||||
const applyLayout = React.useCallback(
|
||||
(layout: Layout) => {
|
||||
if (!layout) return
|
||||
|
||||
const name = value ? value[layout] : `layout-${layout}`
|
||||
const d = document.documentElement
|
||||
|
||||
const handleAttribute = (attr: string) => {
|
||||
if (attr === "class") {
|
||||
d.classList.remove(...attrs)
|
||||
if (name) d.classList.add(name)
|
||||
} else if (attr.startsWith("data-")) {
|
||||
if (name) {
|
||||
d.setAttribute(attr, name)
|
||||
} else {
|
||||
d.removeAttribute(attr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(attribute)) attribute.forEach(handleAttribute)
|
||||
else handleAttribute(attribute)
|
||||
},
|
||||
[attrs, attribute, value]
|
||||
)
|
||||
|
||||
const setLayout = React.useCallback(
|
||||
(value: Layout | ((prev: Layout) => Layout)) => {
|
||||
if (typeof value === "function") {
|
||||
setLayoutState((prevLayout) => {
|
||||
const newLayout = value(prevLayout)
|
||||
saveToLS(storageKey, newLayout)
|
||||
return newLayout
|
||||
})
|
||||
} else {
|
||||
setLayoutState(value)
|
||||
saveToLS(storageKey, value)
|
||||
}
|
||||
},
|
||||
[storageKey]
|
||||
)
|
||||
|
||||
// localStorage event handling
|
||||
React.useEffect(() => {
|
||||
const handleStorage = (e: StorageEvent) => {
|
||||
if (e.key !== storageKey) return
|
||||
|
||||
if (!e.newValue) {
|
||||
setLayout(defaultLayout)
|
||||
} else if (e.newValue === "fixed" || e.newValue === "full") {
|
||||
setLayoutState(e.newValue)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("storage", handleStorage)
|
||||
return () => window.removeEventListener("storage", handleStorage)
|
||||
}, [setLayout, storageKey, defaultLayout])
|
||||
|
||||
// Apply layout on mount and when it changes
|
||||
React.useEffect(() => {
|
||||
const currentLayout = forcedLayout ?? layout
|
||||
applyLayout(currentLayout)
|
||||
}, [forcedLayout, layout, applyLayout])
|
||||
|
||||
// Prevent layout changes during hydration
|
||||
const [isHydrated, setIsHydrated] = React.useState(false)
|
||||
React.useEffect(() => {
|
||||
setIsHydrated(true)
|
||||
}, [])
|
||||
|
||||
const providerValue = React.useMemo(
|
||||
() => ({
|
||||
layout: isHydrated ? layout : defaultLayout,
|
||||
setLayout,
|
||||
forcedLayout,
|
||||
}),
|
||||
[layout, setLayout, forcedLayout, isHydrated, defaultLayout]
|
||||
)
|
||||
|
||||
return (
|
||||
<LayoutContext.Provider value={providerValue}>
|
||||
{children}
|
||||
</LayoutContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const LayoutProvider = (props: LayoutProviderProps) => {
|
||||
const context = React.useContext(LayoutContext)
|
||||
|
||||
// Ignore nested context providers, just passthrough children
|
||||
if (context) return <>{props.children}</>
|
||||
return <Layout {...props} />
|
||||
}
|
||||
|
||||
export { useLayout, LayoutProvider }
|
||||
19
apps/v4/hooks/use-media-query.tsx
Normal file
19
apps/v4/hooks/use-media-query.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import * as React from "react"
|
||||
|
||||
export function useMediaQuery(query: string) {
|
||||
const [value, setValue] = React.useState(false)
|
||||
|
||||
React.useEffect(() => {
|
||||
function onChange(event: MediaQueryListEvent) {
|
||||
setValue(event.matches)
|
||||
}
|
||||
|
||||
const result = matchMedia(query)
|
||||
result.addEventListener("change", onChange)
|
||||
setValue(result.matches)
|
||||
|
||||
return () => result.removeEventListener("change", onChange)
|
||||
}, [query])
|
||||
|
||||
return value
|
||||
}
|
||||
28
apps/v4/hooks/use-meta-color.ts
Normal file
28
apps/v4/hooks/use-meta-color.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import * as React from "react"
|
||||
import { useTheme } from "next-themes"
|
||||
|
||||
export const META_THEME_COLORS = {
|
||||
light: "#ffffff",
|
||||
dark: "#0a0a0a",
|
||||
}
|
||||
|
||||
export function useMetaColor() {
|
||||
const { resolvedTheme } = useTheme()
|
||||
|
||||
const metaColor = React.useMemo(() => {
|
||||
return resolvedTheme !== "dark"
|
||||
? META_THEME_COLORS.light
|
||||
: META_THEME_COLORS.dark
|
||||
}, [resolvedTheme])
|
||||
|
||||
const setMetaColor = React.useCallback((color: string) => {
|
||||
document
|
||||
.querySelector('meta[name="theme-color"]')
|
||||
?.setAttribute("content", color)
|
||||
}, [])
|
||||
|
||||
return {
|
||||
metaColor,
|
||||
setMetaColor,
|
||||
}
|
||||
}
|
||||
17
apps/v4/hooks/use-mobile.ts
Normal file
17
apps/v4/hooks/use-mobile.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import * as React from "react"
|
||||
|
||||
export function useIsMobile(mobileBreakpoint = 768) {
|
||||
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
|
||||
|
||||
React.useEffect(() => {
|
||||
const mql = window.matchMedia(`(max-width: ${mobileBreakpoint - 1}px)`)
|
||||
const onChange = () => {
|
||||
setIsMobile(window.innerWidth < mobileBreakpoint)
|
||||
}
|
||||
mql.addEventListener("change", onChange)
|
||||
setIsMobile(window.innerWidth < mobileBreakpoint)
|
||||
return () => mql.removeEventListener("change", onChange)
|
||||
}, [mobileBreakpoint])
|
||||
|
||||
return !!isMobile
|
||||
}
|
||||
11
apps/v4/hooks/use-mounted.ts
Normal file
11
apps/v4/hooks/use-mounted.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import * as React from "react"
|
||||
|
||||
export function useMounted() {
|
||||
const [mounted, setMounted] = React.useState(false)
|
||||
|
||||
React.useEffect(() => {
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
return mounted
|
||||
}
|
||||
20
apps/v4/hooks/use-mutation-observer.ts
Normal file
20
apps/v4/hooks/use-mutation-observer.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import * as React from "react"
|
||||
|
||||
export const useMutationObserver = (
|
||||
ref: React.RefObject<HTMLElement | null>,
|
||||
callback: MutationCallback,
|
||||
options: MutationObserverInit = {
|
||||
attributes: true,
|
||||
characterData: true,
|
||||
childList: true,
|
||||
subtree: true,
|
||||
}
|
||||
) => {
|
||||
React.useEffect(() => {
|
||||
if (ref.current) {
|
||||
const observer = new MutationObserver(callback)
|
||||
observer.observe(ref.current, options)
|
||||
return () => observer.disconnect()
|
||||
}
|
||||
}, [ref, callback, options])
|
||||
}
|
||||
71
apps/v4/hooks/use-search-registry.ts
Normal file
71
apps/v4/hooks/use-search-registry.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
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("@", "")
|
||||
|
||||
function finderFn<T extends (typeof globalRegistries)[0]>(
|
||||
registry: T,
|
||||
query: string
|
||||
) {
|
||||
const normalizedName = normalizeQuery(registry.name)
|
||||
const normalizedDecription = normalizeQuery(registry.description)
|
||||
const normalizedQuery = normalizeQuery(query)
|
||||
|
||||
return (
|
||||
normalizedName.includes(normalizedQuery) ||
|
||||
normalizedDecription.includes(normalizedQuery)
|
||||
)
|
||||
}
|
||||
|
||||
const searchDirectory = (query: string | null) => {
|
||||
if (!query) return globalRegistries
|
||||
|
||||
return globalRegistries.filter((registry) => finderFn(registry, query))
|
||||
}
|
||||
|
||||
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 {
|
||||
isLoading: !mounted,
|
||||
query: currentQuery,
|
||||
setQuery: (value: string | null) => {
|
||||
setQuery(value)
|
||||
setPage(null)
|
||||
},
|
||||
registries,
|
||||
paginatedRegistries,
|
||||
page: currentPage,
|
||||
totalPages,
|
||||
setPage,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user