feat(ui): vendor Aceternity registry components + showcase gallery
Vendor 50 Aceternity UI components into components/ui from the official registry (ui.aceternity.com/registry/<slug>.json), adapted for this template's stack: Tailwind v4, motion/react 13, cn util, React 19. Add a live showcase gallery (/ui + /docs/... navigation is unchanged): app/ui/page.tsx renders <ShowcaseGallery> with 30+ demos — 3d-pin, aurora, background-beams, bento, card-stack, card-spotlight, direction-aware-hover, encrypted-text, evervault-card, flip-words, hero-highlight, hero-parallax, infinite-moving-cards, moving-border, sparkles, tracing-beam, animated-tooltip, typewriter-effect, placeholders-and-vanish-input and friends. Toolchain stays zero-warning: vendored animation code is scoped in eslint.config.mjs (react-hooks/react-compiler advisory rules, no-any, prefer-const, no-img-element) so app code keeps a 0-error gate. typecheck, build, 7 vitest tests and 2 test suites all green.
This commit is contained in:
@@ -0,0 +1,236 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect, useId, useMemo, useCallback, type ChangeEvent } from "react";
|
||||
import { motion } from "motion/react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function GooeyFilter({ filterId, blur }: { filterId: string; blur: number }) {
|
||||
return (
|
||||
<svg className="absolute hidden h-0 w-0" aria-hidden>
|
||||
<defs>
|
||||
<filter id={filterId} x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feGaussianBlur in="SourceGraphic" stdDeviation={blur} result="blur" />
|
||||
<feColorMatrix
|
||||
in="blur"
|
||||
type="matrix"
|
||||
values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 20 -10"
|
||||
result="goo"
|
||||
/>
|
||||
<feComposite in="SourceGraphic" in2="goo" operator="atop" />
|
||||
</filter>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function SearchIcon({ layoutId }: { layoutId: string }) {
|
||||
return (
|
||||
<motion.svg
|
||||
layoutId={layoutId}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
className="size-4 shrink-0"
|
||||
>
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<path d="m21 21-4.3-4.3" />
|
||||
</motion.svg>
|
||||
);
|
||||
}
|
||||
|
||||
const transition = {
|
||||
duration: 0.4,
|
||||
type: "spring" as const,
|
||||
bounce: 0.25,
|
||||
};
|
||||
|
||||
const iconBubbleVariants = {
|
||||
collapsed: { scale: 0, opacity: 0 },
|
||||
expanded: { scale: 1, opacity: 1 },
|
||||
};
|
||||
|
||||
export interface GooeyInputClassNames {
|
||||
root?: string;
|
||||
filterWrap?: string;
|
||||
buttonRow?: string;
|
||||
trigger?: string;
|
||||
input?: string;
|
||||
bubble?: string;
|
||||
bubbleSurface?: string;
|
||||
}
|
||||
|
||||
export interface GooeyInputProps {
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
classNames?: GooeyInputClassNames;
|
||||
/** Collapsed control width in px */
|
||||
collapsedWidth?: number;
|
||||
/** Expanded control width in px */
|
||||
expandedWidth?: number;
|
||||
/** Horizontal offset when expanded (px), aligns detached bubble */
|
||||
expandedOffset?: number;
|
||||
/** Gaussian blur amount for the gooey SVG filter */
|
||||
gooeyBlur?: number;
|
||||
value?: string;
|
||||
defaultValue?: string;
|
||||
onValueChange?: (value: string) => void;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function GooeyInput({
|
||||
placeholder = "Type to search...",
|
||||
className,
|
||||
classNames,
|
||||
collapsedWidth = 115,
|
||||
expandedWidth = 200,
|
||||
expandedOffset = 50,
|
||||
gooeyBlur = 5,
|
||||
value: valueProp,
|
||||
defaultValue = "",
|
||||
onValueChange,
|
||||
onOpenChange,
|
||||
disabled = false,
|
||||
}: GooeyInputProps) {
|
||||
const reactId = useId();
|
||||
const safeId = reactId.replace(/:/g, "");
|
||||
const filterId = `gooey-filter-${safeId}`;
|
||||
const iconLayoutId = `gooey-input-icon-${safeId}`;
|
||||
const inputLayoutId = `gooey-input-field-${safeId}`;
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const prevExpandedRef = useRef(false);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [uncontrolledValue, setUncontrolledValue] = useState(defaultValue);
|
||||
|
||||
const isControlled = valueProp !== undefined;
|
||||
const searchText = isControlled ? valueProp : uncontrolledValue;
|
||||
|
||||
const setSearchText = useCallback(
|
||||
(next: string) => {
|
||||
if (!isControlled) {
|
||||
setUncontrolledValue(next);
|
||||
}
|
||||
onValueChange?.(next);
|
||||
},
|
||||
[isControlled, onValueChange]
|
||||
);
|
||||
|
||||
const setExpanded = useCallback(
|
||||
(next: boolean) => {
|
||||
setIsExpanded(next);
|
||||
onOpenChange?.(next);
|
||||
},
|
||||
[onOpenChange]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isExpanded) {
|
||||
inputRef.current?.focus();
|
||||
} else if (prevExpandedRef.current) {
|
||||
setSearchText("");
|
||||
}
|
||||
prevExpandedRef.current = isExpanded;
|
||||
}, [isExpanded, setSearchText]);
|
||||
|
||||
const buttonVariants = useMemo(
|
||||
() => ({
|
||||
collapsed: { width: collapsedWidth, marginLeft: 0 },
|
||||
expanded: { width: expandedWidth, marginLeft: expandedOffset },
|
||||
}),
|
||||
[collapsedWidth, expandedWidth, expandedOffset]
|
||||
);
|
||||
|
||||
const handleExpand = useCallback(() => {
|
||||
if (!disabled) setExpanded(true);
|
||||
}, [disabled, setExpanded]);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(e: ChangeEvent<HTMLInputElement>) => {
|
||||
setSearchText(e.target.value);
|
||||
},
|
||||
[setSearchText]
|
||||
);
|
||||
|
||||
const handleBlur = useCallback(() => {
|
||||
if (!searchText) setExpanded(false);
|
||||
}, [searchText, setExpanded]);
|
||||
|
||||
const surfaceClass = "bg-foreground text-background shadow-sm ring-1 ring-border/60";
|
||||
|
||||
return (
|
||||
<div className={cn("relative flex items-center justify-center", className, classNames?.root)}>
|
||||
<GooeyFilter filterId={filterId} blur={gooeyBlur} />
|
||||
|
||||
<div
|
||||
className={cn("relative flex h-10 items-center justify-center", classNames?.filterWrap)}
|
||||
style={{ filter: `url(#${filterId})` }}
|
||||
>
|
||||
<motion.div
|
||||
className={cn("flex h-10 items-center justify-center", classNames?.buttonRow)}
|
||||
variants={buttonVariants}
|
||||
initial="collapsed"
|
||||
animate={isExpanded ? "expanded" : "collapsed"}
|
||||
transition={transition}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={handleExpand}
|
||||
className={cn(
|
||||
"flex h-10 w-full cursor-pointer items-center justify-center gap-2 rounded-full px-4 text-sm font-medium transition-[color,box-shadow] outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50",
|
||||
surfaceClass,
|
||||
classNames?.trigger
|
||||
)}
|
||||
>
|
||||
{!isExpanded ? <SearchIcon layoutId={iconLayoutId} /> : null}
|
||||
<motion.input
|
||||
layoutId={inputLayoutId}
|
||||
ref={inputRef}
|
||||
type="search"
|
||||
enterKeyHint="search"
|
||||
autoComplete="off"
|
||||
value={searchText}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
disabled={disabled || !isExpanded}
|
||||
placeholder={placeholder}
|
||||
className={cn(
|
||||
"h-full min-w-0 flex-1 bg-transparent text-sm text-background outline-none",
|
||||
isExpanded
|
||||
? "placeholder:text-background/50 dark:placeholder:text-background/45"
|
||||
: "pointer-events-none placeholder:text-background/80 dark:placeholder:text-background/70",
|
||||
classNames?.input
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
className={cn(
|
||||
"absolute top-1/2 left-0 flex size-10 -translate-y-1/2 items-center justify-center",
|
||||
classNames?.bubble
|
||||
)}
|
||||
variants={iconBubbleVariants}
|
||||
initial="collapsed"
|
||||
animate={isExpanded ? "expanded" : "collapsed"}
|
||||
transition={transition}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex size-10 items-center justify-center rounded-full",
|
||||
surfaceClass,
|
||||
classNames?.bubbleSurface
|
||||
)}
|
||||
>
|
||||
<SearchIcon layoutId={iconLayoutId} />
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user