initial commit

This commit is contained in:
2026-04-26 20:28:05 +02:00
parent 5542f2cd63
commit b522fd3127
22 changed files with 2537 additions and 0 deletions
+34
View File
@@ -0,0 +1,34 @@
# dependencies (bun install)
node_modules
# output
out
dist
*.tgz
# code coverage
coverage
*.lcov
# logs
logs
_.log
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# caches
.eslintcache
.cache
*.tsbuildinfo
# IntelliJ based IDEs
.idea
# Finder (MacOS) folder config
.DS_Store
+106
View File
@@ -0,0 +1,106 @@
Default to using Bun instead of Node.js.
- Use `bun <file>` instead of `node <file>` or `ts-node <file>`
- Use `bun test` instead of `jest` or `vitest`
- Use `bun build <file.html|file.ts|file.css>` instead of `webpack` or `esbuild`
- Use `bun install` instead of `npm install` or `yarn install` or `pnpm install`
- Use `bun run <script>` instead of `npm run <script>` or `yarn run <script>` or `pnpm run <script>`
- Use `bunx <package> <command>` instead of `npx <package> <command>`
- Bun automatically loads .env, so don't use dotenv.
## APIs
- `Bun.serve()` supports WebSockets, HTTPS, and routes. Don't use `express`.
- `bun:sqlite` for SQLite. Don't use `better-sqlite3`.
- `Bun.redis` for Redis. Don't use `ioredis`.
- `Bun.sql` for Postgres. Don't use `pg` or `postgres.js`.
- `WebSocket` is built-in. Don't use `ws`.
- Prefer `Bun.file` over `node:fs`'s readFile/writeFile
- Bun.$`ls` instead of execa.
## Testing
Use `bun test` to run tests.
```ts#index.test.ts
import { test, expect } from "bun:test";
test("hello world", () => {
expect(1).toBe(1);
});
```
## Frontend
Use HTML imports with `Bun.serve()`. Don't use `vite`. HTML imports fully support React, CSS, Tailwind.
Server:
```ts#index.ts
import index from "./index.html"
Bun.serve({
routes: {
"/": index,
"/api/users/:id": {
GET: (req) => {
return new Response(JSON.stringify({ id: req.params.id }));
},
},
},
// optional websocket support
websocket: {
open: (ws) => {
ws.send("Hello, world!");
},
message: (ws, message) => {
ws.send(message);
},
close: (ws) => {
// handle close
}
},
development: {
hmr: true,
console: true,
}
})
```
HTML files can import .tsx, .jsx or .js files directly and Bun's bundler will transpile & bundle automatically. `<link>` tags can point to stylesheets and Bun's CSS bundler will bundle.
```html#index.html
<html>
<body>
<h1>Hello, world!</h1>
<script type="module" src="./frontend.tsx"></script>
</body>
</html>
```
With the following `frontend.tsx`:
```tsx#frontend.tsx
import React from "react";
import { createRoot } from "react-dom/client";
// import .css files directly and it works
import './index.css';
const root = createRoot(document.body);
export default function Frontend() {
return <h1>Hello, world!</h1>;
}
root.render(<Frontend />);
```
Then, run index.ts
```sh
bun --hot ./index.ts
```
For more information, read the Bun API docs in `node_modules/bun-types/docs/**.mdx`.
+1118
View File
File diff suppressed because it is too large Load Diff
+66
View File
@@ -0,0 +1,66 @@
{
"name": "@avocadi/ui",
"type": "module",
"private": true,
"scripts": {
"build": "tsdown"
},
"devDependencies": {
"@types/bun": "latest",
"tsdown": "^0.21.10"
},
"peerDependencies": {
"@unom/style": "^0.4.4",
"@payloadcms/richtext-lexical": "^3.84.1",
"class-variance-authority": "^0.7.1",
"embla-carousel-react": "^8.6.0",
"lucide-react": "^1.11.0",
"motion": "^12.38.0",
"radix-ui": "^1.4.3",
"react": "^19.2.5",
"typescript": "^6",
"zod": "^4.3.6"
},
"exports": {
"./components/ui/accordion": "./dist/components/ui/accordion.js",
"./components/ui/button": "./dist/components/ui/button.js",
"./components/ui/card": "./dist/components/ui/card.js",
"./components/ui/card-context": "./dist/components/ui/card-context.js",
"./components/ui/carousel": "./dist/components/ui/carousel.js",
"./components/ui/dialog": "./dist/components/ui/dialog.js",
"./components/ui/richtext": "./dist/components/ui/richtext/index.js",
"./components/ui/richtext/converters": "./dist/components/ui/richtext/converters/index.js",
"./components/ui/richtext/converters/headings": "./dist/components/ui/richtext/converters/headings.js",
"./components/ui/richtext/converters/internal-link": "./dist/components/ui/richtext/converters/internal-link.js",
"./components/ui/section": "./dist/components/ui/section/index.js",
"./components/ui/section/heading": "./dist/components/ui/section/heading/index.js",
"./components/ui/section/section.types": "./dist/components/ui/section/section.types.js",
"./components/ui/select": "./dist/components/ui/select.js",
"./components/ui/text": "./dist/components/ui/text.js",
"./lib/utils": "./dist/lib/utils.js",
"./package.json": "./package.json"
},
"dependencies": {
"clsx": "^2.1.1",
"tailwind-merge": "^3.5.0"
},
"inlinedDependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-accordion": "1.2.12",
"@radix-ui/react-collapsible": "1.1.12",
"@radix-ui/react-collection": "1.1.7",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-direction": "1.1.1",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-presence": "1.1.5",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-slot": "1.2.3",
"@radix-ui/react-use-controllable-state": "1.2.2",
"@radix-ui/react-use-layout-effect": "1.1.1",
"framer-motion": "12.38.0",
"motion-dom": "12.38.0",
"motion-utils": "12.36.0",
"react-dom": "19.2.5"
}
}
+68
View File
@@ -0,0 +1,68 @@
"use client";
import * as AccordionPrimitive from "@radix-ui/react-accordion";
import { ChevronDownIcon } from "lucide-react";
import type * as React from "react";
import { cn } from "@/lib/utils";
function Accordion({
...props
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
return <AccordionPrimitive.Root data-slot="accordion" {...props} />;
}
function AccordionItem({
className,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
return (
<AccordionPrimitive.Item
data-slot="accordion-item"
className={cn(" last:border-b-0", className)}
{...props}
/>
);
}
function AccordionTrigger({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
return (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
data-slot="accordion-trigger"
className={cn(
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between \
gap-4 rounded-md py-4 text-left text-sm font-medium outline-none hover:underline \
focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
className,
)}
{...props}
>
{children}
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
);
}
function AccordionContent({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
return (
<AccordionPrimitive.Content
data-slot="accordion-content"
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
{...props}
>
<div className={cn("pt-0 pb-4", className)}>{children}</div>
</AccordionPrimitive.Content>
);
}
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
+64
View File
@@ -0,0 +1,64 @@
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { motion } from "motion/react";
import type * as React from "react";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-full text-sm font-medium transition-color \
disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 \
shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] \
aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default:
"bg-primary text-main dark:text-neutral shadow-xs hover:bg-primary/70",
destructive:
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-primary/25 text-secondary-foreground shadow-xs hover:bg-primary/50 ",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot : "button";
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
}
const AnimatedButton = motion.create(Button);
export { Button, buttonVariants, AnimatedButton };
+16
View File
@@ -0,0 +1,16 @@
import { createContext, useContext } from "react";
const CardDepthContext = createContext(0);
export function useCardDepth() {
return useContext(CardDepthContext);
}
export function CardDepthProvider({ children }: { children: React.ReactNode }) {
const depth = useCardDepth();
return (
<CardDepthContext.Provider value={depth + 1}>
{children}
</CardDepthContext.Provider>
);
}
+86
View File
@@ -0,0 +1,86 @@
import { Slot } from "@radix-ui/react-slot";
import type { VariantProps } from "class-variance-authority";
import { cva } from "class-variance-authority";
import { type HTMLMotionProps, m } from "motion/react";
import { forwardRef, type HTMLAttributes, type ReactNode } from "react";
import { cn } from "@/lib/utils";
import { CardDepthProvider, useCardDepth } from "./card-context";
const cardVariants = cva(
`flex flex-col gap-2 ring-2 rounded-card
ring-accent transition-colors grow justify-start
`,
{
variants: {
padding: {
true: "p-card",
false: "",
},
interactive: {
false: "",
true: "hover:bg-accent/30 cursor-pointer",
},
},
defaultVariants: {
interactive: false,
padding: true,
},
},
);
const BASE_RADIUS = 0.625 * 3;
const PADDING = 1.25;
const MIN_RADIUS = 0;
type CommonCardProps = {
asChild?: boolean;
className?: string;
children: ReactNode;
style?: HTMLAttributes<unknown>["style"];
} & VariantProps<typeof cardVariants>;
const Card = forwardRef<HTMLDivElement, CommonCardProps>(
({ asChild, className, interactive, padding, style, ...props }, ref) => {
const depth = useCardDepth();
const radius = Math.max(BASE_RADIUS - depth * PADDING, MIN_RADIUS); // 4px floor
const Comp = asChild ? Slot : "div";
return (
<CardDepthProvider>
<Comp
ref={ref}
style={{ borderRadius: `${radius}rem`, ...style }}
className={cn(cardVariants({ interactive, className, padding }))}
{...props}
/>
</CardDepthProvider>
);
},
);
Card.displayName = "Card";
const MCard = m.create(Card);
export const AnimatedCard: React.FC<
CommonCardProps & HTMLMotionProps<"div">
> = (props) => {
return (
<MCard
variants={{ enter: { scale: 1 }, from: { scale: 0 } }}
transition={{
type: "spring",
stiffness: 200,
damping: 15,
mass: 2,
}}
{...props}
/>
);
};
export default Card;
export { cardVariants };
+240
View File
@@ -0,0 +1,240 @@
"use client";
import useEmblaCarousel, {
type UseEmblaCarouselType,
} from "embla-carousel-react";
import { ArrowLeft, ArrowRight } from "lucide-react";
import * as React from "react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
type CarouselApi = UseEmblaCarouselType[1];
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>;
type CarouselOptions = UseCarouselParameters[0];
type CarouselPlugin = UseCarouselParameters[1];
type CarouselProps = {
opts?: CarouselOptions;
plugins?: CarouselPlugin;
orientation?: "horizontal" | "vertical";
setApi?: (api: CarouselApi) => void;
};
type CarouselContextProps = {
carouselRef: ReturnType<typeof useEmblaCarousel>[0];
api: ReturnType<typeof useEmblaCarousel>[1];
scrollPrev: () => void;
scrollNext: () => void;
canScrollPrev: boolean;
canScrollNext: boolean;
} & CarouselProps;
const CarouselContext = React.createContext<CarouselContextProps | null>(null);
function useCarousel() {
const context = React.useContext(CarouselContext);
if (!context) {
throw new Error("useCarousel must be used within a <Carousel />");
}
return context;
}
function Carousel({
orientation = "horizontal",
opts,
setApi,
plugins,
className,
children,
...props
}: React.ComponentProps<"div"> & CarouselProps) {
const [carouselRef, api] = useEmblaCarousel(
{
...opts,
axis: orientation === "horizontal" ? "x" : "y",
},
plugins,
);
const [canScrollPrev, setCanScrollPrev] = React.useState(false);
const [canScrollNext, setCanScrollNext] = React.useState(false);
const onSelect = React.useCallback((api: CarouselApi) => {
if (!api) return;
setCanScrollPrev(api.canScrollPrev());
setCanScrollNext(api.canScrollNext());
}, []);
const scrollPrev = React.useCallback(() => {
api?.scrollPrev();
}, [api]);
const scrollNext = React.useCallback(() => {
api?.scrollNext();
}, [api]);
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === "ArrowLeft") {
event.preventDefault();
scrollPrev();
} else if (event.key === "ArrowRight") {
event.preventDefault();
scrollNext();
}
},
[scrollPrev, scrollNext],
);
React.useEffect(() => {
if (!api || !setApi) return;
setApi(api);
}, [api, setApi]);
React.useEffect(() => {
if (!api) return;
onSelect(api);
api.on("reInit", onSelect);
api.on("select", onSelect);
return () => {
api?.off("select", onSelect);
};
}, [api, onSelect]);
return (
<CarouselContext.Provider
value={{
carouselRef,
api: api,
opts,
orientation:
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
scrollPrev,
scrollNext,
canScrollPrev,
canScrollNext,
}}
>
<div
onKeyDownCapture={handleKeyDown}
className={cn("relative", className)}
role="region"
aria-roledescription="carousel"
data-slot="carousel"
{...props}
>
{children}
</div>
</CarouselContext.Provider>
);
}
function CarouselContent({ className, ...props }: React.ComponentProps<"div">) {
const { carouselRef, orientation } = useCarousel();
return (
<div
ref={carouselRef}
className="overflow-hidden"
data-slot="carousel-content"
>
<div
className={cn(
"flex",
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
className,
)}
{...props}
/>
</div>
);
}
function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
const { orientation } = useCarousel();
return (
<div
role="group"
aria-roledescription="slide"
data-slot="carousel-item"
className={cn(
"min-w-0 shrink-0 grow-0 basis-full",
orientation === "horizontal" ? "pl-4" : "pt-4",
className,
)}
{...props}
/>
);
}
function CarouselPrevious({
className,
variant = "outline",
size = "icon",
...props
}: React.ComponentProps<typeof Button>) {
const { orientation, scrollPrev, canScrollPrev } = useCarousel();
return (
<Button
data-slot="carousel-previous"
variant={variant}
size={size}
className={cn(
"absolute size-8 rounded-full",
orientation === "horizontal"
? "top-1/2 -left-12 -translate-y-1/2"
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
className,
)}
disabled={!canScrollPrev}
onClick={scrollPrev}
{...props}
>
<ArrowLeft />
<span className="sr-only">Previous slide</span>
</Button>
);
}
function CarouselNext({
className,
variant = "outline",
size = "icon",
...props
}: React.ComponentProps<typeof Button>) {
const { orientation, scrollNext, canScrollNext } = useCarousel();
return (
<Button
data-slot="carousel-next"
variant={variant}
size={size}
className={cn(
"absolute size-8 rounded-full",
orientation === "horizontal"
? "top-1/2 -right-12 -translate-y-1/2"
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
className,
)}
disabled={!canScrollNext}
onClick={scrollNext}
{...props}
>
<ArrowRight />
<span className="sr-only">Next slide</span>
</Button>
);
}
export {
type CarouselApi,
Carousel,
CarouselContent,
CarouselItem,
CarouselPrevious,
CarouselNext,
};
+148
View File
@@ -0,0 +1,148 @@
"use client";
import { XIcon } from "lucide-react";
import { motion } from "motion/react";
import { Dialog as DialogPrimitive } from "radix-ui";
import type * as React from "react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { cardVariants } from "./card";
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
"fixed inset-0 z-100 bg-black/50 backdrop-blur-lg",
className,
)}
{...props}
/>
);
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean;
}) {
return (
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(className)}
{...props}
>
{children}
</DialogPrimitive.Content>
);
}
const AnimatedDialogContent = motion.create(DialogContent);
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
);
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean;
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className,
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close asChild>
<Button variant="outline">Close</Button>
</DialogPrimitive.Close>
)}
</div>
);
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
);
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
export {
Dialog,
DialogClose,
DialogContent,
AnimatedDialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
};
@@ -0,0 +1,21 @@
import type { SerializedHeadingNode } from "@payloadcms/richtext-lexical";
import type { JSXConverters } from "@payloadcms/richtext-lexical/react";
export const headingConverter: JSXConverters<SerializedHeadingNode> = {
heading: ({ node, nodesToJSX }) => {
if (node.tag === "h2") {
const text = nodesToJSX({ nodes: node.children });
const id = text
.join("")
.toLowerCase()
.replace(/\s+/g, "-")
.replace(/[^a-z0-9-]/g, "");
return <h2 id={id}>{text}</h2>;
} else {
const text = nodesToJSX({ nodes: node.children }).join("");
const Tag = node.tag;
return <Tag>{text}</Tag>;
}
},
};
@@ -0,0 +1,25 @@
import type { DefaultNodeTypes } from "@payloadcms/richtext-lexical";
import {
type JSXConvertersFunction,
LinkJSXConverter,
} from "@payloadcms/richtext-lexical/react";
import { headingConverter } from "./headings";
import { internalDocToHref } from "./internal-link";
type NodeTypes = DefaultNodeTypes;
// Insert blocks like:
// | SerializedBlockNode<TableOfContentsProps | ContentWithMediaProps>;
export const jsxConverter: JSXConvertersFunction<NodeTypes> = ({
defaultConverters,
}) => ({
...defaultConverters,
...LinkJSXConverter({ internalDocToHref }),
...headingConverter,
blocks: {
/* contentWithMedia: ({ node }) => <ContentWithMedia {...node.fields} />,
tableOfContents: ({ node }) => <TableOfContents {...node.fields} />,
*/
},
});
@@ -0,0 +1,21 @@
import type { SerializedLinkNode } from "@payloadcms/richtext-lexical";
export const internalDocToHref = ({
linkNode,
}: {
linkNode: SerializedLinkNode;
}) => {
// biome-ignore lint/style/noNonNullAssertion: safe
const { value, relationTo } = linkNode.fields.doc!;
const slug =
typeof value !== "string" && typeof value !== "number" && value.slug;
if (relationTo === "posts") {
return `/posts/${slug}`;
} else if (relationTo === "users") {
return `/users/${slug}`;
} else {
return `/${slug}`;
}
};
+19
View File
@@ -0,0 +1,19 @@
import type { SerializedEditorState } from "@payloadcms/richtext-lexical/lexical";
import { RichText as RichTextConverter } from "@payloadcms/richtext-lexical/react";
import { jsxConverter } from "./converters";
type Props = {
data: SerializedEditorState;
} & React.HTMLAttributes<HTMLDivElement>;
export function RichText(props: Props) {
const { className, ...rest } = props;
return (
<RichTextConverter
{...rest}
className={className}
converters={jsxConverter}
/>
);
}
@@ -0,0 +1,96 @@
"use client";
import { ease } from "@unom/style";
import { cva, type VariantProps } from "class-variance-authority";
import { motion } from "motion/react";
import type { FC, ReactElement, ReactNode } from "react";
import Text from "@/components/ui/text";
import { cn } from "@/lib/utils";
type HeadingProps = {
/*** Is this the main heading of the page? Uses h1 if true. */
main?: boolean;
/*** Subtitle for additional information */
subtitle?: ReactElement | string;
/*** IconComponent showed above title */
IconComponent?: ReactElement;
children?: ReactNode;
className?: string;
};
const headingVariants = cva("max-w-[800px]", {
variants: {
padding: {
true: "py-4",
false: "p-0",
},
color: {
main: "text-main",
secondary: "text-main/70",
neutral: "text-neutral",
},
},
defaultVariants: {
color: "main",
},
});
const Heading: FC<HeadingProps & VariantProps<typeof headingVariants>> = ({
children,
main = false,
IconComponent,
padding,
color,
subtitle,
className,
}) => {
return (
<div className="relative mb-8">
{IconComponent && (
<>
<div
className="z-20 absolute w-full h-24 top-8 -left-8
bg-linear-to-t from-neutral to-transparent"
/>
<div className="relative z-10">
<motion.div
className="text-highlight
drop-shadow-[0px_0px_10px_hsl(var(--color-highlight)/0.5)]
w-fit h-fit"
transition={ease.quint(2.5).out}
variants={{
enter: { y: 20, scale: 1, x: -30, rotate: -15 },
from: { y: 100, scale: 0, x: 0, rotate: 0 },
}}
>
{IconComponent}
</motion.div>
</div>
</>
)}
<div className="relative z-30 mb-2">
<Text
tag={main ? "h1" : "h2"}
className={cn(
headingVariants({ className, padding, color }),
"my-4",
subtitle ? "mb-2" : main ? "mb-2" : "mb-1",
)}
>
{children}
</Text>
{subtitle && (
<Text
tag="h3"
className="max-w-[600px]"
color={color === "neutral" ? "neutral" : "secondary"}
>
{subtitle}
</Text>
)}
</div>
</div>
);
};
export default Heading;
+61
View File
@@ -0,0 +1,61 @@
"use client";
import { motion, stagger, useInView } from "motion/react";
import { createContext, type FC, useRef } from "react";
import { cn } from "@/lib/utils";
import type { SectionProps } from "./section.types";
export const InViewContext = createContext(false);
const Section: FC<SectionProps> = ({
children,
name,
extraStyles,
maxWidth = true,
extraClassNames = "",
threshold = 0,
height = "fit-content",
transition = { delayChildren: stagger(0.1) },
noPadding = false,
variants = {
container: {
enter: {},
from: {},
exit: {},
},
},
}) => {
const ref = useRef<HTMLElement>(null);
const inView = useInView(ref, { once: true, amount: threshold });
return (
<section
{...(name ? { id: name } : {})}
ref={ref}
style={{
height: height,
...extraStyles,
}}
className={cn(
"relative w-full",
noPadding ? "p-0" : "p-main",
maxWidth ? "max-w-max-section m-auto" : "",
extraClassNames,
)}
>
<InViewContext.Provider value={inView}>
<motion.div
className="h-full"
transition={transition}
initial="from"
animate={inView ? "enter" : "from"}
exit="exit"
variants={variants.container}
>
{children}
</motion.div>
</InViewContext.Provider>
</section>
);
};
export default Section;
@@ -0,0 +1,21 @@
import type { Transition, Variant } from "motion/react";
import type React from "react";
export interface SectionProps {
children: React.ReactNode;
styles?: {
container?: string;
innerContainer?: string;
};
height?: string;
threshold?: number;
extraStyles?: object;
extraClassNames?: string;
name?: string;
transition?: Transition;
noPadding?: boolean;
maxWidth?: boolean;
variants?: {
container: { enter: Variant; from: Variant; exit: Variant };
};
}
+204
View File
@@ -0,0 +1,204 @@
"use client";
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
import { Select as SelectPrimitive } from "radix-ui";
import type * as React from "react";
import { cn } from "@/lib/utils";
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />;
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default";
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"border-main data-[placeholder]:text-secondary [&_svg:not([class*='text-'])]:text-secondary focus-visible:border-ring",
"focus-visible:ring-main/50 aria-invalid:ring-error/20 dark:aria-invalid:ring-error/40 aria-invalid:border-error",
"dark:bg-neutral-accent/30 dark:hover:bg-neutral-accent/50 flex w-fit items-center justify-between gap-2 rounded-full",
"border bg-transparent px-3 py-2 text-sm text-main whitespace-nowrap shadow-xs transition-[color,box-shadow,background-color] outline-none",
"focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-input-height data-[size=sm]:h-8",
"*:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center",
"*:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
);
}
function SelectContent({
className,
children,
position = "item-aligned",
align = "center",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"bg-neutral data-[state=open]:animate-in data-[state=closed]:animate-out p-1",
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
"data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2",
"data-[side=top]:slide-in-from-bottom-2 relative z-100 max-h-(--radix-select-content-available-height) min-w-[8rem]",
"origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-2xl border shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className,
)}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-(--radix-select-trigger-height) w-full min-w-(--radix-select-trigger-width) scroll-my-1",
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
);
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("text-main px-3 py-1.5 text-xs", className)}
{...props}
/>
);
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-main/25 text-main/75 focus:text-main [&_svg:not([class*='text-'])]:text-main transition-colors",
"relative flex w-full cursor-default items-center gap-2 rounded-full py-1.5 pr-8 pl-3",
"text-sm outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50",
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex",
"*:[span]:last:items-center *:[span]:last:gap-2",
className,
)}
{...props}
>
<span
data-slot="select-item-indicator"
className="absolute right-2 flex size-3.5 items-center justify-center"
>
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
);
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("bg-main pointer-events-none -mx-1 my-1 h-px", className)}
{...props}
/>
);
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className,
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
);
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className,
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
);
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
};
+75
View File
@@ -0,0 +1,75 @@
"use client";
import { ease } from "@unom/style";
import { cva, type VariantProps } from "class-variance-authority";
import { type HTMLMotionProps, motion } from "motion/react";
import { type FC, forwardRef, type HTMLProps } from "react";
import { z } from "zod";
import { cn } from "@/lib/utils";
const TextTagHeadingOptions = ["h1", "h2", "h3", "h4", "h5"] as const;
const TextTagContentOptions = ["span", "p"] as const;
export const TextTag = z.enum([
...TextTagHeadingOptions,
...TextTagContentOptions,
]);
const textVariants = cva(null, {
variants: {
color: {
default: "",
main: "",
secondary: "text-secondary",
neutral: "text-neutral",
},
},
});
type Tags = z.output<typeof TextTag>;
type TextProps = {
tag: Tags;
} & HTMLProps<HTMLHeadingElement> &
VariantProps<typeof textVariants>;
const Text = forwardRef<HTMLHeadingElement, TextProps>(
({ tag, color, className, ...props }, ref) => {
const Component = tag;
return (
<Component
ref={ref}
className={cn(textVariants({ color, className }))}
{...props}
>
{props.children}
</Component>
);
},
);
Text.displayName = "Text";
const MText = motion.create(Text);
const AnimatedText: FC<TextProps & HTMLMotionProps<Tags>> = (props) => {
return (
<MText
variants={{
enter: {
y: 0,
opacity: 1,
},
from: {
y: 10,
opacity: 0,
},
}}
transition={ease.quint(0.8).out}
ref={props.ref}
{...props}
/>
);
};
export default AnimatedText;
+6
View File
@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
+33
View File
@@ -0,0 +1,33 @@
{
"compilerOptions": {
// Environment setup & latest features
"lib": ["ESNext"],
"target": "ESNext",
"module": "Preserve",
"moduleDetection": "force",
"jsx": "react-jsx",
"allowJs": true,
"types": ["bun"],
// Bundler mode
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
// Best practices
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
// Some stricter flags (disabled by default)
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false,
"paths": {
"@/*": ["./src/*"]
}
}
}
+9
View File
@@ -0,0 +1,9 @@
import { defineConfig } from "tsdown";
export default defineConfig({
fixedExtension: false,
entry: ["./src/**/*.(ts|tsx)"],
format: "esm",
exports: true,
dts: true,
});