@voltro/ui-shadcn 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +52 -0
- package/LICENSE +57 -0
- package/README.md +26 -0
- package/SECURITY.md +56 -0
- package/THIRD-PARTY-NOTICES.md +3016 -0
- package/dist/brand.d.ts +73 -0
- package/dist/brand.js +177 -0
- package/dist/cn.d.ts +11 -0
- package/dist/cn.js +6 -0
- package/dist/index.d.ts +1183 -0
- package/dist/index.js +2636 -0
- package/dist/tokens.css +532 -0
- package/package.json +64 -0
- package/src/brand/README.md +60 -0
- package/src/brand/assets/voltro-favicon.svg +30 -0
- package/src/brand/assets/voltro-icon-dark.svg +26 -0
- package/src/brand/assets/voltro-icon.svg +14 -0
- package/src/brand/assets/voltro-mark-mono.svg +5 -0
- package/src/brand/assets/voltro-mark.svg +14 -0
- package/src/brand/voltroLogo.tsx +244 -0
- package/src/cn.ts +10 -0
- package/src/compositions/appShell.tsx +72 -0
- package/src/compositions/codeCompare.tsx +88 -0
- package/src/compositions/docShell.tsx +112 -0
- package/src/compositions/docsLayout.tsx +577 -0
- package/src/compositions/featureBento.tsx +103 -0
- package/src/compositions/featureGrid.tsx +41 -0
- package/src/compositions/heroSection.tsx +55 -0
- package/src/compositions/landingCta.tsx +85 -0
- package/src/compositions/landingHero.tsx +174 -0
- package/src/compositions/landingStats.tsx +99 -0
- package/src/compositions/loginCard.tsx +139 -0
- package/src/compositions/pageHeader.tsx +58 -0
- package/src/compositions/profileMenu.tsx +316 -0
- package/src/compositions/siteFooter.tsx +250 -0
- package/src/compositions/themeToggle.tsx +82 -0
- package/src/cookies.ts +109 -0
- package/src/index.ts +160 -0
- package/src/primitives/animatedNumber.tsx +73 -0
- package/src/primitives/avatar.tsx +39 -0
- package/src/primitives/badge.tsx +39 -0
- package/src/primitives/button.tsx +53 -0
- package/src/primitives/callout.tsx +97 -0
- package/src/primitives/card.tsx +68 -0
- package/src/primitives/checkbox.tsx +55 -0
- package/src/primitives/codeBlock.tsx +134 -0
- package/src/primitives/codeWindow.tsx +84 -0
- package/src/primitives/dialog.tsx +43 -0
- package/src/primitives/docCard.tsx +109 -0
- package/src/primitives/docIcons.tsx +268 -0
- package/src/primitives/dropdownMenu.tsx +162 -0
- package/src/primitives/gridOverlay.tsx +51 -0
- package/src/primitives/highlightedCode.tsx +112 -0
- package/src/primitives/input.tsx +25 -0
- package/src/primitives/label.tsx +19 -0
- package/src/primitives/localeSwitcher.tsx +90 -0
- package/src/primitives/meshBackdrop.tsx +62 -0
- package/src/primitives/scrollReveal.tsx +70 -0
- package/src/primitives/searchModal.tsx +304 -0
- package/src/primitives/select.tsx +24 -0
- package/src/primitives/separator.tsx +24 -0
- package/src/primitives/skeleton.tsx +12 -0
- package/src/primitives/sparkles.tsx +105 -0
- package/src/primitives/steps.tsx +55 -0
- package/src/primitives/tabs.tsx +102 -0
- package/src/primitives/textarea.tsx +24 -0
- package/src/primitives/toast.tsx +44 -0
- package/src/primitives/tocScrollSpy.tsx +110 -0
- package/src/primitives/toggle.tsx +50 -0
- package/src/primitives/toggleGroup.tsx +62 -0
- package/src/tokens.css +532 -0
- package/src/widgets.tsx +297 -0
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// shadcn-style Checkbox — native `<input type=checkbox>` (no Radix dep)
|
|
2
|
+
// with indeterminate support. Drives the check / dash glyphs purely via
|
|
3
|
+
// CSS peer variants so it can't desync from the input's real state.
|
|
4
|
+
|
|
5
|
+
import { useEffect, useRef, type InputHTMLAttributes, type ReactNode } from 'react'
|
|
6
|
+
import { cn } from '../cn'
|
|
7
|
+
|
|
8
|
+
export interface CheckboxProps extends InputHTMLAttributes<HTMLInputElement> {
|
|
9
|
+
/** Tri-state middle: renders a dash. Wired to the DOM `indeterminate`
|
|
10
|
+
* property (not an attribute) via a ref effect. */
|
|
11
|
+
readonly indeterminate?: boolean
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const Checkbox = ({ className, indeterminate, ...props }: CheckboxProps): ReactNode => {
|
|
15
|
+
const ref = useRef<HTMLInputElement>(null)
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
if (ref.current) ref.current.indeterminate = indeterminate === true
|
|
18
|
+
}, [indeterminate])
|
|
19
|
+
|
|
20
|
+
return (
|
|
21
|
+
<span className="relative inline-flex size-4 shrink-0 items-center justify-center">
|
|
22
|
+
<input
|
|
23
|
+
ref={ref}
|
|
24
|
+
type="checkbox"
|
|
25
|
+
data-slot="checkbox"
|
|
26
|
+
className={cn(
|
|
27
|
+
'peer size-4 appearance-none rounded-[4px] border shadow-xs outline-none',
|
|
28
|
+
'border-input bg-transparent dark:bg-input/30',
|
|
29
|
+
'checked:bg-primary checked:border-primary',
|
|
30
|
+
'indeterminate:bg-primary indeterminate:border-primary',
|
|
31
|
+
'transition-[color,box-shadow]',
|
|
32
|
+
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
|
|
33
|
+
'disabled:cursor-not-allowed disabled:opacity-50',
|
|
34
|
+
'cursor-pointer',
|
|
35
|
+
className,
|
|
36
|
+
)}
|
|
37
|
+
{...props}
|
|
38
|
+
/>
|
|
39
|
+
{/* checkmark — visible only when checked (and not indeterminate) */}
|
|
40
|
+
<svg
|
|
41
|
+
viewBox="0 0 24 24"
|
|
42
|
+
fill="none"
|
|
43
|
+
stroke="currentColor"
|
|
44
|
+
strokeWidth={3}
|
|
45
|
+
strokeLinecap="round"
|
|
46
|
+
strokeLinejoin="round"
|
|
47
|
+
className="pointer-events-none absolute hidden size-3 text-primary-foreground peer-checked:peer-indeterminate:hidden peer-checked:block"
|
|
48
|
+
>
|
|
49
|
+
<path d="M20 6 9 17l-5-5" />
|
|
50
|
+
</svg>
|
|
51
|
+
{/* indeterminate dash */}
|
|
52
|
+
<span className="pointer-events-none absolute hidden h-[2px] w-2.5 rounded-full bg-primary-foreground peer-indeterminate:block" />
|
|
53
|
+
</span>
|
|
54
|
+
)
|
|
55
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// CodeBlock — pretty wrapper for a `<pre><code>` fragment with an
|
|
2
|
+
// optional filename header, language label, and a one-click copy
|
|
3
|
+
// button. Three rendering modes, in priority order:
|
|
4
|
+
// 1. `html` set → render pre-highlighted Shiki HTML verbatim.
|
|
5
|
+
// (The docs app's MDX pipeline pre-renders code
|
|
6
|
+
// fences at build time and feeds them here.)
|
|
7
|
+
// 2. `code` + `language` matching SHIKI_LANGS → lazy-load Shiki and
|
|
8
|
+
// highlight at runtime via <HighlightedCode>.
|
|
9
|
+
// Until the WASM lands, the body falls back to
|
|
10
|
+
// plain escaped code so layout doesn't jump.
|
|
11
|
+
// 3. `code` only → plain <pre><code>, no highlighting.
|
|
12
|
+
//
|
|
13
|
+
// Browser-only behaviour (copy button) is opt-in: when `interactive:
|
|
14
|
+
// 'islands'` strips the rest of the JS, the button stays a static
|
|
15
|
+
// <button> that doesn't do anything until JS lands; visually still
|
|
16
|
+
// looks right, just non-functional. That's the same compromise the
|
|
17
|
+
// rest of the kit makes.
|
|
18
|
+
//
|
|
19
|
+
// For Fumadocs-style tabs around a CodeBlock, use the `Tabs` primitive
|
|
20
|
+
// in this folder.
|
|
21
|
+
|
|
22
|
+
import { useState, type ReactNode } from 'react'
|
|
23
|
+
import { cn } from '../cn'
|
|
24
|
+
import { HighlightedCode, SHIKI_LANGS, type ShikiLang } from './highlightedCode'
|
|
25
|
+
|
|
26
|
+
const SHIKI_LANG_SET = SHIKI_LANGS as ReadonlyArray<string>
|
|
27
|
+
const asShikiLang = (s: string | undefined): ShikiLang | null =>
|
|
28
|
+
s && SHIKI_LANG_SET.includes(s) ? (s as ShikiLang) : null
|
|
29
|
+
|
|
30
|
+
interface CodeBlockProps {
|
|
31
|
+
/** Filename label shown in the header. Optional. */
|
|
32
|
+
readonly filename?: string
|
|
33
|
+
/** Language label shown in the top-right. Optional. */
|
|
34
|
+
readonly language?: string
|
|
35
|
+
/** Pre-highlighted HTML (e.g. from Shiki) — rendered via
|
|
36
|
+
* dangerouslySetInnerHTML. Pick this OR `code`, not both. */
|
|
37
|
+
readonly html?: string
|
|
38
|
+
/** Plain code text — renders inside a styled <pre><code>. */
|
|
39
|
+
readonly code?: string
|
|
40
|
+
readonly className?: string
|
|
41
|
+
/** Copy-button caption + `aria-label` before a copy. Default `'Copy'` /
|
|
42
|
+
* `'Copy code'`. */
|
|
43
|
+
readonly copyLabel?: string
|
|
44
|
+
readonly copyAriaLabel?: string
|
|
45
|
+
/** Copy-button caption + `aria-label` after a successful copy. Default
|
|
46
|
+
* `'Copied'`. */
|
|
47
|
+
readonly copiedLabel?: string
|
|
48
|
+
readonly copiedAriaLabel?: string
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export const CodeBlock = ({
|
|
52
|
+
filename, language, html, code, className,
|
|
53
|
+
copyLabel = 'Copy', copyAriaLabel = 'Copy code',
|
|
54
|
+
copiedLabel = 'Copied', copiedAriaLabel = 'Copied',
|
|
55
|
+
}: CodeBlockProps): ReactNode => {
|
|
56
|
+
const [copied, setCopied] = useState(false)
|
|
57
|
+
// Text used by the copy button: prefer the raw `code`; when only
|
|
58
|
+
// pre-rendered HTML is available, fall back to stripping tags at
|
|
59
|
+
// copy time.
|
|
60
|
+
const copyText = (): string => {
|
|
61
|
+
if (code) return code
|
|
62
|
+
if (html) {
|
|
63
|
+
const tmp = typeof document !== 'undefined' ? document.createElement('div') : null
|
|
64
|
+
if (!tmp) return ''
|
|
65
|
+
tmp.innerHTML = html
|
|
66
|
+
return tmp.textContent ?? ''
|
|
67
|
+
}
|
|
68
|
+
return ''
|
|
69
|
+
}
|
|
70
|
+
const doCopy = (): void => {
|
|
71
|
+
const text = copyText()
|
|
72
|
+
if (!text || typeof navigator === 'undefined' || !navigator.clipboard) return
|
|
73
|
+
navigator.clipboard.writeText(text).then(() => {
|
|
74
|
+
setCopied(true)
|
|
75
|
+
setTimeout(() => setCopied(false), 1400)
|
|
76
|
+
}).catch(() => { /* clipboard denied; ignore */ })
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return (
|
|
80
|
+
<figure className={cn('not-prose my-5 rounded-lg overflow-hidden border border-border bg-[oklch(0.18_0.005_280)]', className)}>
|
|
81
|
+
{(filename || language) ? (
|
|
82
|
+
<header className="flex items-center justify-between border-b border-border/60 px-4 py-2 bg-card/40">
|
|
83
|
+
{filename ? (
|
|
84
|
+
<span className="text-xs text-muted-foreground font-mono">{filename}</span>
|
|
85
|
+
) : <span />}
|
|
86
|
+
{language ? (
|
|
87
|
+
<span className="text-[0.65rem] uppercase tracking-wider text-muted-foreground/80 font-semibold">
|
|
88
|
+
{language}
|
|
89
|
+
</span>
|
|
90
|
+
) : null}
|
|
91
|
+
</header>
|
|
92
|
+
) : null}
|
|
93
|
+
<div className="relative">
|
|
94
|
+
<button
|
|
95
|
+
type="button"
|
|
96
|
+
onClick={doCopy}
|
|
97
|
+
aria-label={copied ? copiedAriaLabel : copyAriaLabel}
|
|
98
|
+
className="absolute top-3 right-3 inline-flex items-center gap-1 rounded-md border border-border/60 bg-background/70 backdrop-blur px-2 py-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
|
99
|
+
>
|
|
100
|
+
{copied ? (
|
|
101
|
+
<>
|
|
102
|
+
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
|
|
103
|
+
<path d="M3 6.5 L5 8.5 L9 4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
|
104
|
+
</svg>
|
|
105
|
+
{copiedLabel}
|
|
106
|
+
</>
|
|
107
|
+
) : (
|
|
108
|
+
<>
|
|
109
|
+
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
|
|
110
|
+
<rect x="3" y="3" width="6" height="6" rx="1" stroke="currentColor" strokeWidth="1.5" />
|
|
111
|
+
<path d="M5 3 V2.5 A0.5 0.5 0 0 1 5.5 2 H8.5 A0.5 0.5 0 0 1 9 2.5 V5.5 A0.5 0.5 0 0 1 8.5 6 H8" stroke="currentColor" strokeWidth="1.5" />
|
|
112
|
+
</svg>
|
|
113
|
+
{copyLabel}
|
|
114
|
+
</>
|
|
115
|
+
)}
|
|
116
|
+
</button>
|
|
117
|
+
{html ? (
|
|
118
|
+
<div
|
|
119
|
+
className="text-sm leading-relaxed [&_pre]:!m-0 [&_pre]:!bg-transparent [&_pre]:p-4 overflow-x-auto"
|
|
120
|
+
dangerouslySetInnerHTML={{ __html: html }}
|
|
121
|
+
/>
|
|
122
|
+
) : code && asShikiLang(language) ? (
|
|
123
|
+
<HighlightedCode
|
|
124
|
+
code={code}
|
|
125
|
+
lang={asShikiLang(language)!}
|
|
126
|
+
className="[&_pre]:!p-4 [&_pre]:!m-0 [&_pre]:!bg-transparent"
|
|
127
|
+
/>
|
|
128
|
+
) : (
|
|
129
|
+
<pre className="p-4 text-sm leading-relaxed overflow-x-auto"><code>{code}</code></pre>
|
|
130
|
+
)}
|
|
131
|
+
</div>
|
|
132
|
+
</figure>
|
|
133
|
+
)
|
|
134
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// CodeWindow — chrome'd code preview for the landing hero / feature
|
|
2
|
+
// callouts. Looks like a terminal/editor surface without claiming a
|
|
3
|
+
// specific platform (no fake macOS dots).
|
|
4
|
+
//
|
|
5
|
+
// Two states:
|
|
6
|
+
// - Pass `code` (string) + `lang` for a plain (no-syntax) preview
|
|
7
|
+
// - Pass `html` for pre-highlighted (e.g. Shiki) output
|
|
8
|
+
//
|
|
9
|
+
// A subtle blinking caret is appended at the end of the last line
|
|
10
|
+
// when `caret` is true — adds the "live, just typed" feel without
|
|
11
|
+
// real animation cost.
|
|
12
|
+
|
|
13
|
+
import type { ReactNode } from 'react'
|
|
14
|
+
import { cn } from '../cn'
|
|
15
|
+
import { HighlightedCode, type ShikiLang } from './highlightedCode'
|
|
16
|
+
|
|
17
|
+
interface CodeWindowProps {
|
|
18
|
+
/** Title shown in the chrome — usually a filename. */
|
|
19
|
+
readonly title?: string
|
|
20
|
+
/** Tag in the corner — language label, e.g. "TypeScript". */
|
|
21
|
+
readonly tag?: string
|
|
22
|
+
/** Plain code text. When `lang` is also provided, the component
|
|
23
|
+
* runs Shiki syntax highlighting automatically. */
|
|
24
|
+
readonly code?: string
|
|
25
|
+
/** Shiki language. Triggers syntax highlighting on `code`. */
|
|
26
|
+
readonly lang?: ShikiLang
|
|
27
|
+
/** Pre-highlighted HTML (skips Shiki). */
|
|
28
|
+
readonly html?: string
|
|
29
|
+
/** Show a blinking caret after the code. Default false. Only
|
|
30
|
+
* applies to plain (non-highlighted) rendering. */
|
|
31
|
+
readonly caret?: boolean
|
|
32
|
+
/** Outer wrapper class — useful for sizing. */
|
|
33
|
+
readonly className?: string
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const CodeWindow = ({
|
|
37
|
+
title, tag, code, lang, html, caret = false, className,
|
|
38
|
+
}: CodeWindowProps): ReactNode => (
|
|
39
|
+
<div
|
|
40
|
+
className={cn(
|
|
41
|
+
'relative overflow-hidden rounded-xl border border-border bg-card/60 backdrop-blur-sm shadow-2xl',
|
|
42
|
+
// Hairline highlight along the top — sells the "lit from above" feel
|
|
43
|
+
'before:absolute before:inset-x-0 before:top-0 before:h-px before:bg-gradient-to-r before:from-transparent before:via-primary/60 before:to-transparent',
|
|
44
|
+
className,
|
|
45
|
+
)}
|
|
46
|
+
>
|
|
47
|
+
{(title || tag) ? (
|
|
48
|
+
<div className="flex items-center justify-between px-4 py-2 border-b border-border/60 bg-background/40">
|
|
49
|
+
<div className="flex items-center gap-2 text-xs text-muted-foreground font-mono">
|
|
50
|
+
{/* Three soft indicator dots — not platform-specific colours */}
|
|
51
|
+
<span className="inline-flex gap-1.5" aria-hidden="true">
|
|
52
|
+
<span className="w-2.5 h-2.5 rounded-full bg-muted-foreground/30" />
|
|
53
|
+
<span className="w-2.5 h-2.5 rounded-full bg-muted-foreground/30" />
|
|
54
|
+
<span className="w-2.5 h-2.5 rounded-full bg-muted-foreground/30" />
|
|
55
|
+
</span>
|
|
56
|
+
{title ? <span className="ml-2">{title}</span> : null}
|
|
57
|
+
</div>
|
|
58
|
+
{tag ? (
|
|
59
|
+
<span className="text-[0.65rem] uppercase tracking-[0.08em] text-muted-foreground/80 font-semibold">
|
|
60
|
+
{tag}
|
|
61
|
+
</span>
|
|
62
|
+
) : null}
|
|
63
|
+
</div>
|
|
64
|
+
) : null}
|
|
65
|
+
<div className="relative">
|
|
66
|
+
{html ? (
|
|
67
|
+
<div
|
|
68
|
+
className="text-sm leading-relaxed [&_pre]:!m-0 [&_pre]:!bg-transparent [&_pre]:p-5 overflow-x-auto"
|
|
69
|
+
dangerouslySetInnerHTML={{ __html: html }}
|
|
70
|
+
/>
|
|
71
|
+
) : code && lang ? (
|
|
72
|
+
// `code + lang` → run Shiki transparently.
|
|
73
|
+
<HighlightedCode code={code} lang={lang} />
|
|
74
|
+
) : (
|
|
75
|
+
<pre className="p-5 text-sm leading-relaxed overflow-x-auto font-mono">
|
|
76
|
+
<code>{code}</code>
|
|
77
|
+
{caret ? (
|
|
78
|
+
<span className="ml-0.5 inline-block w-[7px] h-[1.1em] -mb-[3px] align-text-bottom bg-primary motion-safe:animate-caret-blink" />
|
|
79
|
+
) : null}
|
|
80
|
+
</pre>
|
|
81
|
+
)}
|
|
82
|
+
</div>
|
|
83
|
+
</div>
|
|
84
|
+
)
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// shadcn Dialog — uses the native HTML <dialog> element. Honest V1:
|
|
2
|
+
// no Radix focus-trap, no portal magic. <dialog> handles modal stacking
|
|
3
|
+
// + ESC dismissal + backdrop click natively in modern browsers; we
|
|
4
|
+
// add the shadcn styling on top.
|
|
5
|
+
//
|
|
6
|
+
// Caller controls open state via the ref or via `open` prop. The
|
|
7
|
+
// imperative API (`dialogRef.current?.showModal()`) is the native
|
|
8
|
+
// path; we surface it as a uncontrolled wrapper for now.
|
|
9
|
+
|
|
10
|
+
import type { DialogHTMLAttributes, HTMLAttributes, ReactNode } from 'react'
|
|
11
|
+
import { cn } from '../cn'
|
|
12
|
+
|
|
13
|
+
export const Dialog = ({ className, children, ...props }: DialogHTMLAttributes<HTMLDialogElement>): ReactNode => (
|
|
14
|
+
<dialog
|
|
15
|
+
data-slot="dialog"
|
|
16
|
+
className={cn(
|
|
17
|
+
'fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2',
|
|
18
|
+
'w-full max-w-lg rounded-lg border bg-background text-foreground p-6 shadow-lg',
|
|
19
|
+
'backdrop:bg-black/50 backdrop:backdrop-blur-sm',
|
|
20
|
+
'open:animate-in open:fade-in-0 open:zoom-in-95',
|
|
21
|
+
className,
|
|
22
|
+
)}
|
|
23
|
+
{...props}
|
|
24
|
+
>
|
|
25
|
+
{children}
|
|
26
|
+
</dialog>
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
export const DialogHeader = ({ className, ...props }: HTMLAttributes<HTMLDivElement>): ReactNode => (
|
|
30
|
+
<div data-slot="dialog-header" className={cn('flex flex-col gap-1.5 text-left mb-4', className)} {...props} />
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
export const DialogTitle = ({ className, ...props }: HTMLAttributes<HTMLHeadingElement>): ReactNode => (
|
|
34
|
+
<h2 data-slot="dialog-title" className={cn('text-lg font-semibold leading-none tracking-tight', className)} {...props} />
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
export const DialogDescription = ({ className, ...props }: HTMLAttributes<HTMLParagraphElement>): ReactNode => (
|
|
38
|
+
<p data-slot="dialog-description" className={cn('text-sm text-muted-foreground', className)} {...props} />
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
export const DialogFooter = ({ className, ...props }: HTMLAttributes<HTMLDivElement>): ReactNode => (
|
|
42
|
+
<div data-slot="dialog-footer" className={cn('mt-6 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', className)} {...props} />
|
|
43
|
+
)
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// DocCard + DocCards — grid of clickable cards for docs landing pages
|
|
2
|
+
// (Fumadocs calls these `<Cards>` / `<Card>`). The wrapper handles the
|
|
3
|
+
// grid; each card is a Link with a hover indicator.
|
|
4
|
+
//
|
|
5
|
+
// Usage:
|
|
6
|
+
// <DocCards>
|
|
7
|
+
// <DocCard
|
|
8
|
+
// title="Getting started"
|
|
9
|
+
// description="Scaffold your first project and boot it locally."
|
|
10
|
+
// href="/docs/intro/getting-started"
|
|
11
|
+
// icon="🚀"
|
|
12
|
+
// />
|
|
13
|
+
// <DocCard … />
|
|
14
|
+
// </DocCards>
|
|
15
|
+
|
|
16
|
+
import type { ComponentType, ReactNode } from 'react'
|
|
17
|
+
import { cn } from '../cn'
|
|
18
|
+
import type { ShellLinkProps } from '../compositions/docShell'
|
|
19
|
+
|
|
20
|
+
interface DocCardsProps {
|
|
21
|
+
readonly children: ReactNode
|
|
22
|
+
readonly className?: string
|
|
23
|
+
/** Override the grid columns. Default: 1 on mobile, 2 on md+. */
|
|
24
|
+
readonly cols?: 1 | 2 | 3
|
|
25
|
+
/** Stagger the first paint — each card fades + slides up with a
|
|
26
|
+
* ~40ms delay. Respects prefers-reduced-motion. Default true. */
|
|
27
|
+
readonly stagger?: boolean
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface DocCardProps {
|
|
31
|
+
readonly title: ReactNode
|
|
32
|
+
readonly description?: ReactNode
|
|
33
|
+
readonly href: string
|
|
34
|
+
readonly icon?: ReactNode
|
|
35
|
+
readonly className?: string
|
|
36
|
+
/** Optional client-router Link; falls back to plain <a>. */
|
|
37
|
+
readonly LinkComponent?: ComponentType<ShellLinkProps>
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const PlainLink = ({ to, className, children }: ShellLinkProps): ReactNode => (
|
|
41
|
+
<a href={to} className={className}>{children}</a>
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
const colClass: Record<NonNullable<DocCardsProps['cols']>, string> = {
|
|
45
|
+
1: 'grid-cols-1',
|
|
46
|
+
2: 'grid-cols-1 md:grid-cols-2',
|
|
47
|
+
3: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3',
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export const DocCards = ({
|
|
51
|
+
children, className, cols = 2, stagger = true,
|
|
52
|
+
}: DocCardsProps): ReactNode => (
|
|
53
|
+
<div
|
|
54
|
+
className={cn(
|
|
55
|
+
'not-prose my-6 grid gap-4',
|
|
56
|
+
colClass[cols],
|
|
57
|
+
stagger && 'stagger-in',
|
|
58
|
+
className,
|
|
59
|
+
)}
|
|
60
|
+
>
|
|
61
|
+
{children}
|
|
62
|
+
</div>
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
export const DocCard = ({
|
|
66
|
+
title, description, href, icon, className, LinkComponent = PlainLink,
|
|
67
|
+
}: DocCardProps): ReactNode => {
|
|
68
|
+
const Link = LinkComponent
|
|
69
|
+
return (
|
|
70
|
+
<Link
|
|
71
|
+
to={href}
|
|
72
|
+
prefetch
|
|
73
|
+
className={cn(
|
|
74
|
+
'group relative block p-5 rounded-lg border border-border bg-card/30',
|
|
75
|
+
'hover:bg-card/60 hover:border-primary/40 hover:-translate-y-0.5',
|
|
76
|
+
'active:translate-y-0 active:scale-[0.99]',
|
|
77
|
+
'transition-[transform,background-color,border-color] duration-150 ease-out',
|
|
78
|
+
// Subtle gradient accent on the top edge — fades in on hover.
|
|
79
|
+
'before:absolute before:inset-x-4 before:top-0 before:h-px before:bg-gradient-to-r before:from-transparent before:via-primary/50 before:to-transparent before:opacity-0 before:transition-opacity group-hover:before:opacity-100',
|
|
80
|
+
className,
|
|
81
|
+
)}
|
|
82
|
+
>
|
|
83
|
+
<div className={cn('flex items-center gap-4', description ? 'mb-4' : '')}>
|
|
84
|
+
{icon ? (
|
|
85
|
+
<div
|
|
86
|
+
className="flex shrink-0 items-center justify-center w-9 h-9 rounded-md bg-primary/10 text-primary [&_svg]:w-5 [&_svg]:h-5"
|
|
87
|
+
aria-hidden="true"
|
|
88
|
+
>
|
|
89
|
+
{icon}
|
|
90
|
+
</div>
|
|
91
|
+
) : null}
|
|
92
|
+
<div className="min-w-0 font-semibold text-foreground group-hover:text-primary transition-colors flex items-center gap-2">
|
|
93
|
+
<span className="min-w-0">{title}</span>
|
|
94
|
+
<span
|
|
95
|
+
className="shrink-0 text-muted-foreground group-hover:text-primary group-hover:translate-x-0.5 transition-all"
|
|
96
|
+
aria-hidden="true"
|
|
97
|
+
>
|
|
98
|
+
→
|
|
99
|
+
</span>
|
|
100
|
+
</div>
|
|
101
|
+
</div>
|
|
102
|
+
{description ? (
|
|
103
|
+
<div className="text-sm text-muted-foreground leading-relaxed">
|
|
104
|
+
{description}
|
|
105
|
+
</div>
|
|
106
|
+
) : null}
|
|
107
|
+
</Link>
|
|
108
|
+
)
|
|
109
|
+
}
|