initial commit
This commit is contained in:
+34
@@ -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
|
||||||
@@ -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`.
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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}`;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
@@ -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 };
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { type ClassValue, clsx } from "clsx";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
@@ -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/*"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { defineConfig } from "tsdown";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
fixedExtension: false,
|
||||||
|
entry: ["./src/**/*.(ts|tsx)"],
|
||||||
|
format: "esm",
|
||||||
|
exports: true,
|
||||||
|
dts: true,
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user