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.
237 lines
7.0 KiB
TypeScript
237 lines
7.0 KiB
TypeScript
"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>
|
|
);
|
|
}
|