feat: rate limit moved to core

This commit is contained in:
Bereket Engida
2024-09-25 00:12:41 +03:00
parent ac2501d28e
commit d792e21341
95 changed files with 8019 additions and 524 deletions

View File

@@ -16,5 +16,8 @@
"[json]": {
"editor.defaultFormatter": "biomejs.biome"
},
"typescript.tsdk": "node_modules/typescript/lib"
"typescript.tsdk": "node_modules/typescript/lib",
"[astro]": {
"editor.defaultFormatter": "biomejs.biome"
}
}

View File

@@ -50,7 +50,7 @@ const PasswordInput = React.forwardRef<HTMLInputElement, InputProps>(
`}</style>
</div>
);
},
}
);
PasswordInput.displayName = "PasswordInput";

View File

@@ -52,7 +52,10 @@ export const Tabs = ({
}}
onMouseEnter={() => setHovering(true)}
onMouseLeave={() => setHovering(false)}
className={cn("relative px-4 py-2 rounded-full opacity-80 hover:opacity-100", tabClassName)}
className={cn(
"relative px-4 py-2 rounded-full opacity-80 hover:opacity-100",
tabClassName
)}
style={{
transformStyle: "preserve-3d",
}}
@@ -63,21 +66,26 @@ export const Tabs = ({
duration: 0.2,
delay: 0.1,
type: "keyframes"
type: "keyframes",
}}
animate={{
x: tabs.indexOf(tab) === 0 ? [0, 0, 0] : [0, 0, 0],
}}
className={cn(
"absolute inset-0 bg-gray-200 dark:bg-zinc-900/90 opacity-100",
activeTabClassName,
activeTabClassName
)}
/>
)}
<span className={
cn("relative block text-black dark:text-white", active.value === tab.value ? "text-opacity-100 font-medium" : "opacity-40 ")
}>
<span
className={cn(
"relative block text-black dark:text-white",
active.value === tab.value
? "text-opacity-100 font-medium"
: "opacity-40 "
)}
>
{tab.title}
</span>
</button>
@@ -121,10 +129,14 @@ export const FadeInDiv = ({
transition: {
duration: 0.2,
delay: 0.1,
type: "keyframes"
}
type: "keyframes",
},
}}
className={cn("w-50 h-full", isActive(tab) ? "" : "hidden", className)}
className={cn(
"w-50 h-full",
isActive(tab) ? "" : "hidden",
className
)}
>
{tab.content}
</motion.div>

View File

@@ -157,7 +157,6 @@ export const contents: Content[] = [
),
href: "/docs/concepts/database",
},
{
href: "/docs/concepts/plugins",
title: "Plugins",
@@ -179,6 +178,11 @@ export const contents: Content[] = [
</svg>
),
},
{
title: "Rate Limit",
icon: Clock,
href: "/docs/concepts/rate-limit",
},
{
title: "Session Management",
href: "/docs/concepts/session-management",
@@ -556,11 +560,7 @@ export const contents: Content[] = [
href: "/docs/plugins/1st-party-plugins",
icon: LucideAArrowDown,
},
{
title: "Rate Limit",
icon: Clock,
href: "/docs/plugins/rate-limit",
},
{
title: "Bearer",
icon: Key,

View File

@@ -0,0 +1,132 @@
---
title: Rate Limit
description: How to limit the number of requests a user can make to the server in a given time period.
---
Better Auth includes a built-in rate limiter to help manage traffic and prevent abuse. By default, in production mode, the rate limiter is set to:
- Window: 60 seconds
- Max Requests: 100 requests
You can easily customize these settings by passing the rateLimit plugin to the betterAuth function.
In addition to the default settings, Better Auth provides custom rules for specific paths. For example:
"/sign-in/email": Limited to 7 requests within 10 seconds.
In addition to that, plugins also define custom rules for specific paths. For example, `twoFactor` plugin has the following custom rules:
- "/two-factor/verify": Limited to 3 requests within 10 seconds.
These custom rules ensure that sensitive operations are protected with stricter limits.
## Configuring Rate Limit
### Rate Limit Window
```ts title="auth.ts"
import { betterAuth } from "better-auth";
export const auth = betterAuth({
//...other options
rateLimit: {
window: 60, // time window in seconds
max: 100, // max requests in the window
customRules: { // custom rules for specific paths
"/sign-in/email": {
window: 10,
max: 7,
},
},
},
})
```
### Storage
By default, rate limit data is stored in memory. However, you can either use your database or custom storage to store rate limit data.
**Using Database**
```ts title="auth.ts"
import { betterAuth } from "better-auth";
export const auth = betterAuth({
//...other options
rateLimit: {
storage: "database",
tableName: "rateLimit", //optional by default "rateLimit" is used
},
})
```
make sure to run `migrate` to create the rate limit table in your database.
```bash
npx better-auth migrate
```
**Custom Storage**
```ts title="auth.ts"
import { betterAuth } from "better-auth";
export const auth = betterAuth({
//...other options
rateLimit: {
customStorage: {
get: async (key) => {
// get rate limit data
},
set: async (key, value) => {
// set rate limit data
},
},
},
})
```
## Handling Rate Limit Errors
When a request exceeds the rate limit, Better Auth returns the following header:
- `X-Retry-After`: The number of seconds until the user can make another request.
To handle rate limit errors on the client side, you can manage them either globally or on a per-request basis. Since Better Auth clients wrap over Better Fetch, you can pass fetchOptions to handle rate limit errors
**Global Handling**
```ts title="client.ts"
import { createAuthClient } from "better-auth/client";
export const client = createAuthClient({
fetchOptions: {
onError: async (context) => {
const { response } = context;
if (response.status === 429) {
const retryAfter = response.headers.get("X-Retry-After");
console.log(`Rate limit exceeded. Retry after ${retryAfter} seconds`);
}
},
}
})
```
**Per Request Handling**
```ts title="client.ts"
import { client } from "./client";
await client.signIn.email({
fetchOptions: {
onError: async (context) => {
const { response } = context;
if (response.status === 429) {
const retryAfter = response.headers.get("X-Retry-After");
console.log(`Rate limit exceeded. Retry after ${retryAfter} seconds`);
}
},
}
})
```

View File

@@ -3,8 +3,12 @@ import { defineConfig } from 'astro/config';
import tailwind from "@astrojs/tailwind";
import solidJs from "@astrojs/solid-js";
// https://astro.build/config
export default defineConfig({
output: "server",
integrations: [tailwind()]
integrations: [tailwind({
applyBaseStyles: false
}), solidJs()]
});

View File

@@ -0,0 +1,16 @@
{
"$schema": "https://shadcn-solid.com/schema.json",
"tailwind": {
"config": "tailwind.config.mjs",
"css": {
"path": "src/app.css",
"variable": true
},
"color": "stone",
"prefix": ""
},
"alias": {
"component": "@/components",
"cn": "@/libs/cn"
}
}

View File

@@ -10,12 +10,31 @@
"astro": "astro"
},
"dependencies": {
"@ark-ui/solid": "^3.12.1",
"@astrojs/check": "^0.9.3",
"@astrojs/solid-js": "^4.4.2",
"@astrojs/tailwind": "^5.1.1",
"@corvu/drawer": "^0.2.2",
"@corvu/otp-field": "^0.1.2",
"@corvu/resizable": "^0.2.3",
"@kobalte/core": "^0.13.6",
"@oslojs/encoding": "^1.0.0",
"astro": "^4.15.9",
"better-auth": "workspace:*",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"cmdk-solid": "^1.1.0",
"embla-carousel-solid": "^8.3.0",
"lucide-solid": "^0.445.0",
"solid-js": "^1.8.23",
"solid-sonner": "^0.2.8",
"tailwind-merge": "^2.5.2",
"tailwindcss": "^3.4.13",
"typescript": "^5.6.2"
"tailwindcss-animate": "^1.0.7",
"typescript": "^5.6.2",
"ua-parser-js": "^0.7.39"
},
"devDependencies": {
"@types/ua-parser-js": "^0.7.39"
}
}

View File

@@ -0,0 +1,76 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 20 14.3% 4.1%;
--card: 0 0% 100%;
--card-foreground: 20 14.3% 4.1%;
--popover: 0 0% 100%;
--popover-foreground: 20 14.3% 4.1%;
--primary: 24 9.8% 10%;
--primary-foreground: 60 9.1% 97.8%;
--secondary: 60 4.8% 95.9%;
--secondary-foreground: 24 9.8% 10%;
--muted: 60 4.8% 95.9%;
--muted-foreground: 25 5.3% 44.7%;
--accent: 60 4.8% 95.9%;
--accent-foreground: 24 9.8% 10%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 60 9.1% 97.8%;
--border: 20 5.9% 90%;
--input: 20 5.9% 90%;
--ring: 20 14.3% 4.1%;
--radius: 0.5rem;
}
[data-kb-theme="dark"] {
--background: 20 14.3% 4.1%;
--foreground: 60 9.1% 97.8%;
--card: 20 14.3% 4.1%;
--card-foreground: 60 9.1% 97.8%;
--popover: 20 14.3% 4.1%;
--popover-foreground: 60 9.1% 97.8%;
--primary: 60 9.1% 97.8%;
--primary-foreground: 24 9.8% 10%;
--secondary: 12 6.5% 15.1%;
--secondary-foreground: 60 9.1% 97.8%;
--muted: 12 6.5% 15.1%;
--muted-foreground: 24 5.4% 63.9%;
--accent: 12 6.5% 15.1%;
--accent-foreground: 60 9.1% 97.8%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 60 9.1% 97.8%;
--border: 12 6.5% 15.1%;
--input: 12 6.5% 15.1%;
--ring: 24 5.7% 82.9%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}

View File

@@ -1,4 +1,5 @@
import { betterAuth } from "better-auth";
import { passkey, twoFactor, rateLimiter } from "better-auth/plugins";
export const auth = betterAuth({
database: {
@@ -11,10 +12,23 @@ export const auth = betterAuth({
trustedProviders: ["google"],
},
},
emailAndPassword: {
enabled: true,
},
socialProviders: {
google: {
clientId: import.meta.env.GOOGLE_CLIENT_ID || "",
clientSecret: import.meta.env.GOOGLE_CLIENT_SECRET || "",
},
},
plugins: [
passkey(),
twoFactor(),
rateLimiter({
enabled: true,
storage: {
provider: "memory",
},
}),
],
});

View File

@@ -0,0 +1,16 @@
export function Loader() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
viewBox="0 0 24 24"
class="animate-spin"
>
<path
fill="currentColor"
d="M12 22c5.421 0 10-4.579 10-10h-2c0 4.337-3.663 8-8 8s-8-3.663-8-8s3.663-8 8-8V2C6.579 2 2 6.58 2 12c0 5.421 4.579 10 10 10"
></path>
</svg>
);
}

View File

@@ -0,0 +1,174 @@
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { TextField, TextFieldLabel, TextFieldRoot } from "./ui/textfield";
import { Button } from "./ui/button";
import { Checkbox, CheckboxControl, CheckboxLabel } from "./ui/checkbox";
import { passkeyActions, signIn } from "@/libs/auth-client";
import { createSignal } from "solid-js";
export function SignInCard() {
const [email, setEmail] = createSignal("");
const [password, setPassword] = createSignal("");
const [rememberMe, setRememberMe] = createSignal(false);
return (
<Card class="max-w-max">
<CardHeader>
<CardTitle class="text-lg md:text-xl">Sign In</CardTitle>
<CardDescription class="text-xs md:text-sm">
Enter your email below to login to your account
</CardDescription>
</CardHeader>
<CardContent>
<div class="grid gap-4">
<div class="grid gap-2">
<TextFieldRoot class="w-full">
<TextFieldLabel for="email">Email</TextFieldLabel>
<TextField
type="email"
placeholder="Email"
value={email()}
onInput={(e) => {
if ("value" in e.target) setEmail(e.target.value as string);
}}
/>
</TextFieldRoot>
<TextFieldRoot class="w-full">
<div class="flex items-center justify-between">
<TextFieldLabel for="password">Password</TextFieldLabel>
<a
href="/forget-password"
class="ml-auto inline-block text-sm underline"
>
Forgot your password?
</a>
</div>
<TextField
type="password"
placeholder="Password"
value={password()}
onInput={(e) => {
if ("value" in e.target)
setPassword(e.target.value as string);
}}
/>
</TextFieldRoot>
<Checkbox
class="flex items-center gap-2 z-50"
onChange={(e) => {
setRememberMe(e);
}}
checked={rememberMe()}
>
<CheckboxControl />
<CheckboxLabel class="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
Remember Me
</CheckboxLabel>
</Checkbox>
<Button
onclick={() => {
signIn.email({
email: email(),
password: password(),
dontRememberMe: !rememberMe(),
fetchOptions: {
onError(context) {
alert(context.error.message);
},
},
callbackURL: "/",
});
}}
>
Sign In
</Button>
<Button class="gap-2" variant="outline">
<svg
xmlns="http://www.w3.org/2000/svg"
width="1.2em"
height="1.2em"
viewBox="0 0 24 24"
>
<path
fill="currentColor"
d="M12 2A10 10 0 0 0 2 12c0 4.42 2.87 8.17 6.84 9.5c.5.08.66-.23.66-.5v-1.69c-2.77.6-3.36-1.34-3.36-1.34c-.46-1.16-1.11-1.47-1.11-1.47c-.91-.62.07-.6.07-.6c1 .07 1.53 1.03 1.53 1.03c.87 1.52 2.34 1.07 2.91.83c.09-.65.35-1.09.63-1.34c-2.22-.25-4.55-1.11-4.55-4.92c0-1.11.38-2 1.03-2.71c-.1-.25-.45-1.29.1-2.64c0 0 .84-.27 2.75 1.02c.79-.22 1.65-.33 2.5-.33s1.71.11 2.5.33c1.91-1.29 2.75-1.02 2.75-1.02c.55 1.35.2 2.39.1 2.64c.65.71 1.03 1.6 1.03 2.71c0 3.82-2.34 4.66-4.57 4.91c.36.31.69.92.69 1.85V21c0 .27.16.59.67.5C19.14 20.16 22 16.42 22 12A10 10 0 0 0 12 2"
></path>
</svg>
Continue with Github
</Button>
<Button class="gap-2" variant="outline">
<svg
xmlns="http://www.w3.org/2000/svg"
width="1.2em"
height="1.2em"
viewBox="0 0 512 512"
>
<path
fill="currentColor"
d="m473.16 221.48l-2.26-9.59H262.46v88.22H387c-12.93 61.4-72.93 93.72-121.94 93.72c-35.66 0-73.25-15-98.13-39.11a140.08 140.08 0 0 1-41.8-98.88c0-37.16 16.7-74.33 41-98.78s61-38.13 97.49-38.13c41.79 0 71.74 22.19 82.94 32.31l62.69-62.36C390.86 72.72 340.34 32 261.6 32c-60.75 0-119 23.27-161.58 65.71C58 139.5 36.25 199.93 36.25 256s20.58 113.48 61.3 155.6c43.51 44.92 105.13 68.4 168.58 68.4c57.73 0 112.45-22.62 151.45-63.66c38.34-40.4 58.17-96.3 58.17-154.9c0-24.67-2.48-39.32-2.59-39.96"
></path>
</svg>
Continue with Google
</Button>
<Button
class="gap-2"
variant="outline"
onClick={async () => {
await signIn.passkey({
callbackURL: "/dashboard",
fetchOptions: {
onError(context) {
alert(context.error.message);
},
},
});
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="lucide lucide-key"
>
<path d="m15.5 7.5 2.3 2.3a1 1 0 0 0 1.4 0l2.1-2.1a1 1 0 0 0 0-1.4L19 4" />
<path d="m21 2-9.6 9.6" />
<circle cx="7.5" cy="15.5" r="5.5" />
</svg>
Sign-In with Passkey
</Button>
</div>
<p class="text-sm text-center">
Don't have an account yet?{" "}
<a
href="/sign-up"
class="text-blue-900 dark:text-orange-200 underline"
>
Sign Up
</a>
</p>
</div>
</CardContent>
<CardFooter class="flex-col">
<div class="flex justify-center w-full border-t py-4">
<p class="text-center text-xs text-neutral-500">
Secured by{" "}
<span class="text-orange-900 dark:text-orange-200">
better-auth.
</span>
</p>
</div>
</CardFooter>
</Card>
);
}

View File

@@ -0,0 +1,164 @@
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { TextField, TextFieldLabel, TextFieldRoot } from "./ui/textfield";
import { Button } from "./ui/button";
import { Checkbox, CheckboxControl, CheckboxLabel } from "./ui/checkbox";
import { signIn, signUp } from "@/libs/auth-client";
import { createSignal } from "solid-js";
import { convertImageToBase64 } from "@/libs/utils";
export function SignUpCard() {
const [firstName, setFirstName] = createSignal("");
const [lastName, setLastName] = createSignal("");
const [email, setEmail] = createSignal("");
const [password, setPassword] = createSignal("");
const [image, setImage] = createSignal<File>();
const [rememberMe, setRememberMe] = createSignal(false);
return (
<Card>
<CardHeader>
<CardTitle class="text-lg md:text-xl">Sign Up</CardTitle>
<CardDescription class="text-xs md:text-sm">
Enter your information to create an account
</CardDescription>
</CardHeader>
<CardContent>
<div class="grid gap-4">
<div class="grid gap-2">
<div class="flex items-center gap-2">
<TextFieldRoot class="w-full">
<TextFieldLabel for="name">First Name</TextFieldLabel>
<TextField
type="first-name"
placeholder="First Name"
value={firstName()}
onInput={(e) => {
if ("value" in e.target)
setFirstName(e.target.value as string);
}}
/>
</TextFieldRoot>
<TextFieldRoot class="w-full">
<TextFieldLabel for="name">Last Name</TextFieldLabel>
<TextField
type="last-name"
placeholder="Last Name"
value={lastName()}
onInput={(e) => {
if ("value" in e.target)
setLastName(e.target.value as string);
}}
/>
</TextFieldRoot>
</div>
<TextFieldRoot class="w-full">
<TextFieldLabel for="email">Email</TextFieldLabel>
<TextField
type="email"
placeholder="Email"
value={email()}
onInput={(e) => {
if ("value" in e.target) setEmail(e.target.value as string);
}}
/>
</TextFieldRoot>
<TextFieldRoot class="w-full">
<TextFieldLabel for="password">Password</TextFieldLabel>
<TextField
type="password"
placeholder="Password"
value={password()}
onInput={(e) => {
if ("value" in e.target)
setPassword(e.target.value as string);
}}
/>
</TextFieldRoot>
<TextFieldRoot>
<TextFieldLabel>Image</TextFieldLabel>
<TextField
type="file"
accept="image/*"
placeholder="Image"
onChange={(e: any) => {
const file = e.target.files?.[0];
if ("value" in e.target) setImage(file);
}}
/>
</TextFieldRoot>
<Button
onclick={async () => {
signUp.email({
name: `${firstName()} ${lastName()}`,
image: image()
? await convertImageToBase64(image()!)
: undefined,
email: email(),
password: password(),
callbackURL: "/",
fetchOptions: {
onError(context) {
alert(context.error.message);
},
},
});
}}
>
Sign Up
</Button>
<Button class="gap-2" variant="outline">
<svg
xmlns="http://www.w3.org/2000/svg"
width="1.2em"
height="1.2em"
viewBox="0 0 24 24"
>
<path
fill="currentColor"
d="M12 2A10 10 0 0 0 2 12c0 4.42 2.87 8.17 6.84 9.5c.5.08.66-.23.66-.5v-1.69c-2.77.6-3.36-1.34-3.36-1.34c-.46-1.16-1.11-1.47-1.11-1.47c-.91-.62.07-.6.07-.6c1 .07 1.53 1.03 1.53 1.03c.87 1.52 2.34 1.07 2.91.83c.09-.65.35-1.09.63-1.34c-2.22-.25-4.55-1.11-4.55-4.92c0-1.11.38-2 1.03-2.71c-.1-.25-.45-1.29.1-2.64c0 0 .84-.27 2.75 1.02c.79-.22 1.65-.33 2.5-.33s1.71.11 2.5.33c1.91-1.29 2.75-1.02 2.75-1.02c.55 1.35.2 2.39.1 2.64c.65.71 1.03 1.6 1.03 2.71c0 3.82-2.34 4.66-4.57 4.91c.36.31.69.92.69 1.85V21c0 .27.16.59.67.5C19.14 20.16 22 16.42 22 12A10 10 0 0 0 12 2"
></path>
</svg>
Continue with Github
</Button>
<Button class="gap-2" variant="outline">
<svg
xmlns="http://www.w3.org/2000/svg"
width="1.2em"
height="1.2em"
viewBox="0 0 512 512"
>
<path
fill="currentColor"
d="m473.16 221.48l-2.26-9.59H262.46v88.22H387c-12.93 61.4-72.93 93.72-121.94 93.72c-35.66 0-73.25-15-98.13-39.11a140.08 140.08 0 0 1-41.8-98.88c0-37.16 16.7-74.33 41-98.78s61-38.13 97.49-38.13c41.79 0 71.74 22.19 82.94 32.31l62.69-62.36C390.86 72.72 340.34 32 261.6 32c-60.75 0-119 23.27-161.58 65.71C58 139.5 36.25 199.93 36.25 256s20.58 113.48 61.3 155.6c43.51 44.92 105.13 68.4 168.58 68.4c57.73 0 112.45-22.62 151.45-63.66c38.34-40.4 58.17-96.3 58.17-154.9c0-24.67-2.48-39.32-2.59-39.96"
></path>
</svg>
Continue with Google
</Button>
</div>
<p class="text-sm text-center">
Already have an account?{" "}
<a href="/sign-in" class="text-blue-500">
Sign In
</a>
</p>
</div>
</CardContent>
<CardFooter class="flex-col">
<div class="flex justify-center w-full border-t py-4">
<p class="text-center text-xs text-neutral-500">
Secured by{" "}
<span class="text-orange-900 dark:text-orange-200">
better-auth.
</span>
</p>
</div>
</CardFooter>
</Card>
);
}

View File

@@ -0,0 +1,188 @@
import { createEffect, createSignal, Show } from "solid-js";
import { Button } from "./ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "./ui/card";
import {
OTPField,
OTPFieldGroup,
OTPFieldInput,
OTPFieldSeparator,
OTPFieldSlot,
} from "./ui/otp-field";
import { twoFactorActions } from "@/libs/auth-client";
export function TwoFactorComponent() {
const [otp, setOTP] = createSignal("");
createEffect(() => {
if (otp().length === 6) {
twoFactorActions.verifyTotp({
code: otp(),
callbackURL: "/dashboard",
fetchOptions: {
onError(context) {
if (context.error.status === 429) {
const retryAfter = context.response.headers.get("X-Retry-After");
alert(
`Too many requests. Please try again after ${retryAfter} seconds`
);
} else {
alert(
context.error.message ||
context.error.statusText ||
context.error.status
);
}
},
},
});
}
});
return (
<main class="flex flex-col items-center justify-center min-h-[calc(100vh-10rem)]">
<Card class="w-[350px]">
<CardHeader>
<CardTitle>TOTP Verification</CardTitle>
<CardDescription>
Enter your 6-digit TOTP code to authenticate
</CardDescription>
</CardHeader>
<CardContent>
<div class="flex flex-col gap-2 items-center">
<OTPField
maxLength={6}
value={otp()}
onValueChange={(value) => {
setOTP(value);
}}
>
<OTPFieldInput />
<OTPFieldGroup>
<OTPFieldSlot index={0} />
<OTPFieldSlot index={1} />
<OTPFieldSlot index={2} />
<OTPFieldSlot index={3} />
<OTPFieldSlot index={4} />
<OTPFieldSlot index={5} />
</OTPFieldGroup>
</OTPField>
<span class="text-center text-xs">
Enter your one-time password.
</span>
</div>
<div class="flex justify-center">
<a
href="/two-factor/email"
class="text-xs border-b pb-1 mt-2 w-max hover:border-black transition-all"
>
Switch to Email Verification
</a>
</div>
</CardContent>
</Card>
</main>
);
}
export function TwoFactorEmail() {
const [otp, setOTP] = createSignal("");
createEffect(() => {
if (otp().length === 6) {
twoFactorActions.verifyTotp({
code: otp(),
callbackURL: "/dashboard",
fetchOptions: {
onError(context) {
alert(context.error.message);
},
},
});
}
});
const [sentEmail, setSentEmail] = createSignal(false);
return (
<main class="flex flex-col items-center justify-center min-h-[calc(100vh-10rem)]">
<Card class="w-[350px]">
<CardHeader>
<CardTitle>Email Verification</CardTitle>
<CardDescription>
Enter your 6-digit TOTP code to authenticate
</CardDescription>
</CardHeader>
<CardContent>
<Show
when={sentEmail()}
fallback={
<Button
onClick={async () => {
await twoFactorActions.sendOtp({
fetchOptions: {
onSuccess(context) {
setSentEmail(true);
},
onError(context) {
alert(context.error.message);
},
},
});
}}
class="w-full gap-2"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="1.2em"
height="1.2em"
viewBox="0 0 24 24"
>
<path
fill="currentColor"
d="M4 20q-.825 0-1.412-.587T2 18V6q0-.825.588-1.412T4 4h16q.825 0 1.413.588T22 6v12q0 .825-.587 1.413T20 20zm8-7.175q.125 0 .263-.038t.262-.112L19.6 8.25q.2-.125.3-.312t.1-.413q0-.5-.425-.75T18.7 6.8L12 11L5.3 6.8q-.45-.275-.875-.012T4 7.525q0 .25.1.438t.3.287l7.075 4.425q.125.075.263.113t.262.037"
></path>
</svg>{" "}
Send OTP to Email
</Button>
}
>
<div class="flex flex-col gap-2 items-center">
<OTPField
maxLength={6}
value={otp()}
onValueChange={(value) => {
setOTP(value);
}}
>
<OTPFieldInput />
<OTPFieldGroup>
<OTPFieldSlot index={0} />
<OTPFieldSlot index={1} />
<OTPFieldSlot index={2} />
<OTPFieldSlot index={3} />
<OTPFieldSlot index={4} />
<OTPFieldSlot index={5} />
</OTPFieldGroup>
</OTPField>
<span class="text-center text-xs">
Enter your one-time password.
</span>
</div>
</Show>
<div class="flex justify-center">
<a
href="/two-factor"
class="text-xs border-b pb-1 mt-2 w-max hover:border-black transition-all"
>
Switch to TOTP Verification
</a>
</div>
</CardContent>
</Card>
</main>
);
}

View File

@@ -0,0 +1,97 @@
import { cn } from "@/libs/cn";
import type {
AccordionContentProps,
AccordionItemProps,
AccordionTriggerProps,
} from "@kobalte/core/accordion";
import { Accordion as AccordionPrimitive } from "@kobalte/core/accordion";
import type { PolymorphicProps } from "@kobalte/core/polymorphic";
import { type ParentProps, type ValidComponent, splitProps } from "solid-js";
export const Accordion = AccordionPrimitive;
type accordionItemProps<T extends ValidComponent = "div"> =
AccordionItemProps<T> & {
class?: string;
};
export const AccordionItem = <T extends ValidComponent = "div">(
props: PolymorphicProps<T, accordionItemProps<T>>,
) => {
const [local, rest] = splitProps(props as accordionItemProps, ["class"]);
return (
<AccordionPrimitive.Item class={cn("border-b", local.class)} {...rest} />
);
};
type accordionTriggerProps<T extends ValidComponent = "button"> = ParentProps<
AccordionTriggerProps<T> & {
class?: string;
}
>;
export const AccordionTrigger = <T extends ValidComponent = "button">(
props: PolymorphicProps<T, accordionTriggerProps<T>>,
) => {
const [local, rest] = splitProps(props as accordionTriggerProps, [
"class",
"children",
]);
return (
<AccordionPrimitive.Header class="flex" as="div">
<AccordionPrimitive.Trigger
class={cn(
"flex flex-1 items-center justify-between py-4 text-sm font-medium transition-shadow hover:underline focus-visible:outline-none focus-visible:ring-[1.5px] focus-visible:ring-ring [&[data-expanded]>svg]:rotate-180",
local.class,
)}
{...rest}
>
{local.children}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
class="h-4 w-4 text-muted-foreground transition-transform duration-200"
>
<path
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="m6 9l6 6l6-6"
/>
<title>Arrow</title>
</svg>
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
);
};
type accordionContentProps<T extends ValidComponent = "div"> = ParentProps<
AccordionContentProps<T> & {
class?: string;
}
>;
export const AccordionContent = <T extends ValidComponent = "div">(
props: PolymorphicProps<T, accordionContentProps<T>>,
) => {
const [local, rest] = splitProps(props as accordionContentProps, [
"class",
"children",
]);
return (
<AccordionPrimitive.Content
class={cn(
"animate-accordion-up overflow-hidden text-sm data-[expanded]:animate-accordion-down",
local.class,
)}
{...rest}
>
<div class="pb-4 pt-0">{local.children}</div>
</AccordionPrimitive.Content>
);
};

View File

@@ -0,0 +1,152 @@
import { cn } from "@/libs/cn";
import type {
AlertDialogCloseButtonProps,
AlertDialogContentProps,
AlertDialogDescriptionProps,
AlertDialogTitleProps,
} from "@kobalte/core/alert-dialog";
import { AlertDialog as AlertDialogPrimitive } from "@kobalte/core/alert-dialog";
import type { PolymorphicProps } from "@kobalte/core/polymorphic";
import type { ComponentProps, ParentProps, ValidComponent } from "solid-js";
import { splitProps } from "solid-js";
import { buttonVariants } from "./button";
export const AlertDialog = AlertDialogPrimitive;
export const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
type alertDialogContentProps<T extends ValidComponent = "div"> = ParentProps<
AlertDialogContentProps<T> & {
class?: string;
}
>;
export const AlertDialogContent = <T extends ValidComponent = "div">(
props: PolymorphicProps<T, alertDialogContentProps<T>>,
) => {
const [local, rest] = splitProps(props as alertDialogContentProps, [
"class",
"children",
]);
return (
<AlertDialogPrimitive.Portal>
<AlertDialogPrimitive.Overlay
class={cn(
"fixed inset-0 z-50 bg-background/80 data-[expanded]:animate-in data-[closed]:animate-out data-[closed]:fade-out-0 data-[expanded]:fade-in-0",
)}
/>
<AlertDialogPrimitive.Content
class={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg data-[closed]:duration-200 data-[expanded]:duration-200 data-[expanded]:animate-in data-[closed]:animate-out data-[closed]:fade-out-0 data-[expanded]:fade-in-0 data-[closed]:zoom-out-95 data-[expanded]:zoom-in-95 data-[closed]:slide-out-to-left-1/2 data-[closed]:slide-out-to-top-[48%] data-[expanded]:slide-in-from-left-1/2 data-[expanded]:slide-in-from-top-[48%] sm:rounded-lg md:w-full",
local.class,
)}
{...rest}
>
{local.children}
</AlertDialogPrimitive.Content>
</AlertDialogPrimitive.Portal>
);
};
export const AlertDialogHeader = (props: ComponentProps<"div">) => {
const [local, rest] = splitProps(props, ["class"]);
return (
<div
class={cn(
"flex flex-col space-y-2 text-center sm:text-left",
local.class,
)}
{...rest}
/>
);
};
export const AlertDialogFooter = (props: ComponentProps<"div">) => {
const [local, rest] = splitProps(props, ["class"]);
return (
<div
class={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
local.class,
)}
{...rest}
/>
);
};
type alertDialogTitleProps<T extends ValidComponent = "h2"> =
AlertDialogTitleProps<T> & {
class?: string;
};
export const AlertDialogTitle = <T extends ValidComponent = "h2">(
props: PolymorphicProps<T, alertDialogTitleProps<T>>,
) => {
const [local, rest] = splitProps(props as alertDialogTitleProps, ["class"]);
return (
<AlertDialogPrimitive.Title
class={cn("text-lg font-semibold", local.class)}
{...rest}
/>
);
};
type alertDialogDescriptionProps<T extends ValidComponent = "p"> =
AlertDialogDescriptionProps<T> & {
class?: string;
};
export const AlertDialogDescription = <T extends ValidComponent = "p">(
props: PolymorphicProps<T, alertDialogDescriptionProps<T>>,
) => {
const [local, rest] = splitProps(props as alertDialogDescriptionProps, [
"class",
]);
return (
<AlertDialogPrimitive.Description
class={cn("text-sm text-muted-foreground", local.class)}
{...rest}
/>
);
};
type alertDialogCloseProps<T extends ValidComponent = "button"> =
AlertDialogCloseButtonProps<T> & {
class?: string;
};
export const AlertDialogClose = <T extends ValidComponent = "button">(
props: PolymorphicProps<T, alertDialogCloseProps<T>>,
) => {
const [local, rest] = splitProps(props as alertDialogCloseProps, ["class"]);
return (
<AlertDialogPrimitive.CloseButton
class={cn(
buttonVariants({
variant: "outline",
}),
"mt-2 md:mt-0",
local.class,
)}
{...rest}
/>
);
};
export const AlertDialogAction = <T extends ValidComponent = "button">(
props: PolymorphicProps<T, alertDialogCloseProps<T>>,
) => {
const [local, rest] = splitProps(props as alertDialogCloseProps, ["class"]);
return (
<AlertDialogPrimitive.CloseButton
class={cn(buttonVariants(), local.class)}
{...rest}
/>
);
};

View File

@@ -0,0 +1,66 @@
import { cn } from "@/libs/cn";
import type { AlertRootProps } from "@kobalte/core/alert";
import { Alert as AlertPrimitive } from "@kobalte/core/alert";
import type { PolymorphicProps } from "@kobalte/core/polymorphic";
import type { VariantProps } from "class-variance-authority";
import { cva } from "class-variance-authority";
import type { ComponentProps, ValidComponent } from "solid-js";
import { splitProps } from "solid-js";
export const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm [&:has(svg)]:pl-11 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
{
variants: {
variant: {
default: "bg-background text-foreground",
destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
},
},
defaultVariants: {
variant: "default",
},
},
);
type alertProps<T extends ValidComponent = "div"> = AlertRootProps<T> &
VariantProps<typeof alertVariants> & {
class?: string;
};
export const Alert = <T extends ValidComponent = "div">(
props: PolymorphicProps<T, alertProps<T>>,
) => {
const [local, rest] = splitProps(props as alertProps, ["class", "variant"]);
return (
<AlertPrimitive
class={cn(
alertVariants({
variant: props.variant,
}),
local.class,
)}
{...rest}
/>
);
};
export const AlertTitle = (props: ComponentProps<"div">) => {
const [local, rest] = splitProps(props, ["class"]);
return (
<div
class={cn("font-medium leading-5 tracking-tight", local.class)}
{...rest}
/>
);
};
export const AlertDescription = (props: ComponentProps<"div">) => {
const [local, rest] = splitProps(props, ["class"]);
return (
<div class={cn("text-sm [&_p]:leading-relaxed", local.class)} {...rest} />
);
};

View File

@@ -0,0 +1,42 @@
import { cn } from "@/libs/cn";
import type { VariantProps } from "class-variance-authority";
import { cva } from "class-variance-authority";
import { type ComponentProps, splitProps } from "solid-js";
export const badgeVariants = cva(
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-shadow focus-visible:outline-none focus-visible:ring-[1.5px] focus-visible:ring-ring",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
},
);
export const Badge = (
props: ComponentProps<"div"> & VariantProps<typeof badgeVariants>,
) => {
const [local, rest] = splitProps(props, ["class", "variant"]);
return (
<div
class={cn(
badgeVariants({
variant: local.variant,
}),
local.class,
)}
{...rest}
/>
);
};

View File

@@ -0,0 +1,66 @@
import { cn } from "@/libs/cn";
import type { ButtonRootProps } from "@kobalte/core/button";
import { Button as ButtonPrimitive } from "@kobalte/core/button";
import type { PolymorphicProps } from "@kobalte/core/polymorphic";
import type { VariantProps } from "class-variance-authority";
import { cva } from "class-variance-authority";
import type { ValidComponent } from "solid-js";
import { splitProps } from "solid-js";
export const buttonVariants = cva(
"inline-flex items-center justify-center rounded-md text-sm font-medium transition-[color,background-color,box-shadow] focus-visible:outline-none focus-visible:ring-[1.5px] focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline:
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
type buttonProps<T extends ValidComponent = "button"> = ButtonRootProps<T> &
VariantProps<typeof buttonVariants> & {
class?: string;
};
export const Button = <T extends ValidComponent = "button">(
props: PolymorphicProps<T, buttonProps<T>>,
) => {
const [local, rest] = splitProps(props as buttonProps, [
"class",
"variant",
"size",
]);
return (
<ButtonPrimitive
class={cn(
buttonVariants({
size: local.size,
variant: local.variant,
}),
local.class,
)}
{...rest}
/>
);
};

View File

@@ -0,0 +1,60 @@
import { cn } from "@/libs/cn";
import type { ComponentProps, ParentComponent } from "solid-js";
import { splitProps } from "solid-js";
export const Card = (props: ComponentProps<"div">) => {
const [local, rest] = splitProps(props, ["class"]);
return (
<div
class={cn(
"rounded-xl border bg-card text-card-foreground shadow",
local.class,
)}
{...rest}
/>
);
};
export const CardHeader = (props: ComponentProps<"div">) => {
const [local, rest] = splitProps(props, ["class"]);
return (
<div class={cn("flex flex-col space-y-1.5 p-6", local.class)} {...rest} />
);
};
export const CardTitle: ParentComponent<ComponentProps<"h1">> = (props) => {
const [local, rest] = splitProps(props, ["class"]);
return (
<h1
class={cn("font-semibold leading-none tracking-tight", local.class)}
{...rest}
/>
);
};
export const CardDescription: ParentComponent<ComponentProps<"h3">> = (
props,
) => {
const [local, rest] = splitProps(props, ["class"]);
return (
<h3 class={cn("text-sm text-muted-foreground", local.class)} {...rest} />
);
};
export const CardContent = (props: ComponentProps<"div">) => {
const [local, rest] = splitProps(props, ["class"]);
return <div class={cn("p-6 pt-0", local.class)} {...rest} />;
};
export const CardFooter = (props: ComponentProps<"div">) => {
const [local, rest] = splitProps(props, ["class"]);
return (
<div class={cn("flex items-center p-6 pt-0", local.class)} {...rest} />
);
};

View File

@@ -0,0 +1,272 @@
import { cn } from "@/libs/cn";
import type { CreateEmblaCarouselType } from "embla-carousel-solid";
import createEmblaCarousel from "embla-carousel-solid";
import type {
Accessor,
ComponentProps,
ParentProps,
VoidProps,
} from "solid-js";
import {
createContext,
createEffect,
createMemo,
createSignal,
mergeProps,
onCleanup,
splitProps,
useContext,
} from "solid-js";
import { Button } from "./button";
export type CarouselApi = CreateEmblaCarouselType[1];
type UseCarouselParameters = Parameters<typeof createEmblaCarousel>;
type CarouselOptions = NonNullable<UseCarouselParameters[0]>;
type CarouselPlugin = NonNullable<UseCarouselParameters[1]>;
type CarouselProps = {
opts?: ReturnType<CarouselOptions>;
plugins?: ReturnType<CarouselPlugin>;
orientation?: "horizontal" | "vertical";
setApi?: (api: CarouselApi) => void;
};
type CarouselContextProps = {
carouselRef: ReturnType<typeof createEmblaCarousel>[0];
api: ReturnType<typeof createEmblaCarousel>[1];
scrollPrev: () => void;
scrollNext: () => void;
canScrollPrev: Accessor<boolean>;
canScrollNext: Accessor<boolean>;
} & CarouselProps;
const CarouselContext = createContext<Accessor<CarouselContextProps> | null>(
null,
);
const useCarousel = () => {
const context = useContext(CarouselContext);
if (!context) {
throw new Error("useCarousel must be used within a <Carousel />");
}
return context();
};
export const Carousel = (props: ComponentProps<"div"> & CarouselProps) => {
const merge = mergeProps<
ParentProps<ComponentProps<"div"> & CarouselProps>[]
>({ orientation: "horizontal" }, props);
const [local, rest] = splitProps(merge, [
"orientation",
"opts",
"setApi",
"plugins",
"class",
"children",
]);
const [carouselRef, api] = createEmblaCarousel(
() => ({
...local.opts,
axis: local.orientation === "horizontal" ? "x" : "y",
}),
() => (local.plugins === undefined ? [] : local.plugins),
);
const [canScrollPrev, setCanScrollPrev] = createSignal(false);
const [canScrollNext, setCanScrollNext] = createSignal(false);
const onSelect = (api: NonNullable<ReturnType<CarouselApi>>) => {
setCanScrollPrev(api.canScrollPrev());
setCanScrollNext(api.canScrollNext());
};
const scrollPrev = () => api()?.scrollPrev();
const scrollNext = () => api()?.scrollNext();
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "ArrowLeft") {
event.preventDefault();
scrollPrev();
} else if (event.key === "ArrowRight") {
event.preventDefault();
scrollNext();
}
};
createEffect(() => {
if (!api() || !local.setApi) return;
local.setApi(api);
});
createEffect(() => {
const _api = api();
if (_api === undefined) return;
onSelect(_api);
_api.on("reInit", onSelect);
_api.on("select", onSelect);
onCleanup(() => {
_api.off("select", onSelect);
});
});
const value = createMemo(
() =>
({
carouselRef,
api,
opts: local.opts,
orientation:
local.orientation ||
(local.opts?.axis === "y" ? "vertical" : "horizontal"),
scrollPrev,
scrollNext,
canScrollPrev,
canScrollNext,
}) satisfies CarouselContextProps,
);
return (
<CarouselContext.Provider value={value}>
<div
onKeyDown={handleKeyDown}
class={cn("relative", local.class)}
role="region"
aria-roledescription="carousel"
{...rest}
>
{local.children}
</div>
</CarouselContext.Provider>
);
};
export const CarouselContent = (props: ComponentProps<"div">) => {
const [local, rest] = splitProps(props, ["class"]);
const { carouselRef, orientation } = useCarousel();
return (
<div ref={carouselRef} class="overflow-hidden">
<div
class={cn(
"flex",
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
local.class,
)}
{...rest}
/>
</div>
);
};
export const CarouselItem = (props: ComponentProps<"div">) => {
const [local, rest] = splitProps(props, ["class"]);
const { orientation } = useCarousel();
return (
<div
role="group"
aria-roledescription="slide"
class={cn(
"min-w-0 shrink-0 grow-0 basis-full",
orientation === "horizontal" ? "pl-4" : "pt-4",
local.class,
)}
{...rest}
/>
);
};
export const CarouselPrevious = (
props: VoidProps<ComponentProps<typeof Button>>,
) => {
const merge = mergeProps<VoidProps<ComponentProps<typeof Button>[]>>(
{ variant: "outline", size: "icon" },
props,
);
const [local, rest] = splitProps(merge, ["class", "variant", "size"]);
const { orientation, scrollPrev, canScrollPrev } = useCarousel();
return (
<Button
variant={local.variant}
size={local.size}
class={cn(
"absolute h-8 w-8 touch-manipulation rounded-full",
orientation === "horizontal"
? "-left-12 top-1/2 -translate-y-1/2"
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
local.class,
)}
disabled={!canScrollPrev()}
onClick={scrollPrev}
{...rest}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
class="size-4"
>
<path
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 12h14M5 12l6 6m-6-6l6-6"
/>
<title>Previous slide</title>
</svg>
</Button>
);
};
export const CarouselNext = (
props: VoidProps<ComponentProps<typeof Button>>,
) => {
const merge = mergeProps<VoidProps<ComponentProps<typeof Button>[]>>(
{ variant: "outline", size: "icon" },
props,
);
const [local, rest] = splitProps(merge, ["class", "variant", "size"]);
const { orientation, scrollNext, canScrollNext } = useCarousel();
return (
<Button
variant={local.variant}
size={local.size}
class={cn(
"absolute h-8 w-8 touch-manipulation rounded-full",
orientation === "horizontal"
? "-right-12 top-1/2 -translate-y-1/2"
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
local.class,
)}
disabled={!canScrollNext()}
onClick={scrollNext}
{...rest}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
class="size-4"
>
<path
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 12h14m-4 4l4-4m-4-4l4 4"
/>
<title>Next slide</title>
</svg>
</Button>
);
};

View File

@@ -0,0 +1,55 @@
import { cn } from "@/libs/cn";
import type { CheckboxControlProps } from "@kobalte/core/checkbox";
import { Checkbox as CheckboxPrimitive } from "@kobalte/core/checkbox";
import type { PolymorphicProps } from "@kobalte/core/polymorphic";
import type { ValidComponent, VoidProps } from "solid-js";
import { splitProps } from "solid-js";
export const CheckboxLabel = CheckboxPrimitive.Label;
export const Checkbox = CheckboxPrimitive;
export const CheckboxErrorMessage = CheckboxPrimitive.ErrorMessage;
export const CheckboxDescription = CheckboxPrimitive.Description;
type checkboxControlProps<T extends ValidComponent = "div"> = VoidProps<
CheckboxControlProps<T> & { class?: string }
>;
export const CheckboxControl = <T extends ValidComponent = "div">(
props: PolymorphicProps<T, checkboxControlProps<T>>,
) => {
const [local, rest] = splitProps(props as checkboxControlProps, [
"class",
"children",
]);
return (
<>
<CheckboxPrimitive.Input class="[&:focus-visible+div]:outline-none [&:focus-visible+div]:ring-[1.5px] [&:focus-visible+div]:ring-ring [&:focus-visible+div]:ring-offset-2 [&:focus-visible+div]:ring-offset-background" />
<CheckboxPrimitive.Control
class={cn(
"h-4 w-4 shrink-0 rounded-sm border border-primary shadow transition-shadow focus-visible:outline-none focus-visible:ring-[1.5px] focus-visible:ring-ring data-[disabled]:cursor-not-allowed data-[checked]:bg-primary data-[checked]:text-primary-foreground data-[disabled]:opacity-50",
local.class,
)}
{...rest}
>
<CheckboxPrimitive.Indicator class="flex items-center justify-center text-current">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
class="h-4 w-4"
>
<path
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="m5 12l5 5L20 7"
/>
<title>Checkbox</title>
</svg>
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Control>
</>
);
};

View File

@@ -0,0 +1,31 @@
import { cn } from "@/libs/cn";
import type { CollapsibleContentProps } from "@kobalte/core/collapsible";
import { Collapsible as CollapsiblePrimitive } from "@kobalte/core/collapsible";
import type { PolymorphicProps } from "@kobalte/core/polymorphic";
import type { ValidComponent } from "solid-js";
import { splitProps } from "solid-js";
export const Collapsible = CollapsiblePrimitive;
export const CollapsibleTrigger = CollapsiblePrimitive.Trigger;
type collapsibleContentProps<T extends ValidComponent = "div"> =
CollapsibleContentProps<T> & {
class?: string;
};
export const CollapsibleContent = <T extends ValidComponent = "div">(
props: PolymorphicProps<T, collapsibleContentProps<T>>,
) => {
const [local, rest] = splitProps(props as collapsibleContentProps, ["class"]);
return (
<CollapsiblePrimitive.Content
class={cn(
"animate-collapsible-up data-[expanded]:animate-collapsible-down",
local.class,
)}
{...rest}
/>
);
};

View File

@@ -0,0 +1,156 @@
import { cn } from "@/libs/cn";
import type {
ComboboxContentProps,
ComboboxInputProps,
ComboboxItemProps,
ComboboxTriggerProps,
} from "@kobalte/core/combobox";
import { Combobox as ComboboxPrimitive } from "@kobalte/core/combobox";
import type { PolymorphicProps } from "@kobalte/core/polymorphic";
import type { ParentProps, ValidComponent, VoidProps } from "solid-js";
import { splitProps } from "solid-js";
export const Combobox = ComboboxPrimitive;
export const ComboboxDescription = ComboboxPrimitive.Description;
export const ComboboxErrorMessage = ComboboxPrimitive.ErrorMessage;
export const ComboboxItemDescription = ComboboxPrimitive.ItemDescription;
export const ComboboxHiddenSelect = ComboboxPrimitive.HiddenSelect;
type comboboxInputProps<T extends ValidComponent = "input"> = VoidProps<
ComboboxInputProps<T> & {
class?: string;
}
>;
export const ComboboxInput = <T extends ValidComponent = "input">(
props: PolymorphicProps<T, comboboxInputProps<T>>,
) => {
const [local, rest] = splitProps(props as comboboxInputProps, ["class"]);
return (
<ComboboxPrimitive.Input
class={cn(
"h-full bg-transparent text-sm placeholder:text-muted-foreground focus:outline-none disabled:cursor-not-allowed disabled:opacity-50",
local.class,
)}
{...rest}
/>
);
};
type comboboxTriggerProps<T extends ValidComponent = "button"> = ParentProps<
ComboboxTriggerProps<T> & {
class?: string;
}
>;
export const ComboboxTrigger = <T extends ValidComponent = "button">(
props: PolymorphicProps<T, comboboxTriggerProps<T>>,
) => {
const [local, rest] = splitProps(props as comboboxTriggerProps, [
"class",
"children",
]);
return (
<ComboboxPrimitive.Control>
<ComboboxPrimitive.Trigger
class={cn(
"flex h-9 w-full items-center justify-between rounded-md border border-input px-3 shadow-sm",
local.class,
)}
{...rest}
>
{local.children}
<ComboboxPrimitive.Icon class="flex h-3.5 w-3.5 items-center justify-center">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
class="h-4 w-4 opacity-50"
>
<path
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="m8 9l4-4l4 4m0 6l-4 4l-4-4"
/>
<title>Arrow</title>
</svg>
</ComboboxPrimitive.Icon>
</ComboboxPrimitive.Trigger>
</ComboboxPrimitive.Control>
);
};
type comboboxContentProps<T extends ValidComponent = "div"> =
ComboboxContentProps<T> & {
class?: string;
};
export const ComboboxContent = <T extends ValidComponent = "div">(
props: PolymorphicProps<T, comboboxContentProps<T>>,
) => {
const [local, rest] = splitProps(props as comboboxContentProps, ["class"]);
return (
<ComboboxPrimitive.Portal>
<ComboboxPrimitive.Content
class={cn(
"relative z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[expanded]:animate-in data-[closed]:animate-out data-[closed]:fade-out-0 data-[expanded]:fade-in-0 data-[closed]:zoom-out-95 data-[expanded]:zoom-in-95 origin-[--kb-combobox-content-transform-origin]",
local.class,
)}
{...rest}
>
<ComboboxPrimitive.Listbox class="p-1" />
</ComboboxPrimitive.Content>
</ComboboxPrimitive.Portal>
);
};
type comboboxItemProps<T extends ValidComponent = "li"> = ParentProps<
ComboboxItemProps<T> & {
class?: string;
}
>;
export const ComboboxItem = <T extends ValidComponent = "li">(
props: PolymorphicProps<T, comboboxItemProps<T>>,
) => {
const [local, rest] = splitProps(props as comboboxItemProps, [
"class",
"children",
]);
return (
<ComboboxPrimitive.Item
class={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none data-[disabled]:pointer-events-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground data-[disabled]:opacity-50",
local.class,
)}
{...rest}
>
<ComboboxPrimitive.ItemIndicator class="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
class="h-4 w-4"
>
<path
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="m5 12l5 5L20 7"
/>
<title>Checked</title>
</svg>
</ComboboxPrimitive.ItemIndicator>
<ComboboxPrimitive.ItemLabel>
{local.children}
</ComboboxPrimitive.ItemLabel>
</ComboboxPrimitive.Item>
);
};

View File

@@ -0,0 +1,151 @@
import { cn } from "@/libs/cn";
import type {
CommandDialogProps,
CommandEmptyProps,
CommandGroupProps,
CommandInputProps,
CommandItemProps,
CommandListProps,
CommandRootProps,
} from "cmdk-solid";
import { Command as CommandPrimitive } from "cmdk-solid";
import type { ComponentProps, VoidProps } from "solid-js";
import { splitProps } from "solid-js";
import { Dialog, DialogContent } from "./dialog";
export const Command = (props: CommandRootProps) => {
const [local, rest] = splitProps(props, ["class"]);
return (
<CommandPrimitive
class={cn(
"flex size-full flex-col overflow-hidden bg-popover text-popover-foreground",
local.class,
)}
{...rest}
/>
);
};
export const CommandList = (props: CommandListProps) => {
const [local, rest] = splitProps(props, ["class"]);
return (
<CommandPrimitive.List
class={cn(
"max-h-[300px] overflow-y-auto overflow-x-hidden p-1",
local.class,
)}
{...rest}
/>
);
};
export const CommandInput = (props: VoidProps<CommandInputProps>) => {
const [local, rest] = splitProps(props, ["class"]);
return (
<div class="flex items-center border-b px-3" cmdk-input-wrapper="">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
class="mr-2 h-4 w-4 shrink-0 opacity-50"
>
<path
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M3 10a7 7 0 1 0 14 0a7 7 0 1 0-14 0m18 11l-6-6"
/>
<title>Search</title>
</svg>
<CommandPrimitive.Input
class={cn(
"flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
local.class,
)}
{...rest}
/>
</div>
);
};
export const CommandItem = (props: CommandItemProps) => {
const [local, rest] = splitProps(props, ["class"]);
return (
<CommandPrimitive.Item
class={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none aria-disabled:pointer-events-none aria-disabled:opacity-50 aria-selected:bg-accent aria-selected:text-accent-foreground",
local.class,
)}
{...rest}
/>
);
};
export const CommandShortcut = (props: ComponentProps<"span">) => {
const [local, rest] = splitProps(props, ["class"]);
return (
<span
class={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
local.class,
)}
{...rest}
/>
);
};
export const CommandDialog = (props: CommandDialogProps) => {
const [local, rest] = splitProps(props, ["children"]);
return (
<Dialog {...rest}>
<DialogContent class="overflow-hidden p-0">
<Command class="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:size-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:size-5">
{local.children}
</Command>
</DialogContent>
</Dialog>
);
};
export const CommandEmpty = (props: CommandEmptyProps) => {
const [local, rest] = splitProps(props, ["class"]);
return (
<CommandPrimitive.Empty
class={cn("py-6 text-center text-sm", local.class)}
{...rest}
/>
);
};
export const CommandGroup = (props: CommandGroupProps) => {
const [local, rest] = splitProps(props, ["class"]);
return (
<CommandPrimitive.Group
class={cn(
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
local.class,
)}
{...rest}
/>
);
};
export const CommandSeparator = (props: CommandEmptyProps) => {
const [local, rest] = splitProps(props, ["class"]);
return (
<CommandPrimitive.Separator
class={cn("-mx-1 h-px bg-border", local.class)}
{...rest}
/>
);
};

View File

@@ -0,0 +1,327 @@
import { cn } from "@/libs/cn";
import type {
ContextMenuCheckboxItemProps,
ContextMenuContentProps,
ContextMenuGroupLabelProps,
ContextMenuItemLabelProps,
ContextMenuItemProps,
ContextMenuRadioItemProps,
ContextMenuSeparatorProps,
ContextMenuSubContentProps,
ContextMenuSubTriggerProps,
} from "@kobalte/core/context-menu";
import { ContextMenu as ContextMenuPrimitive } from "@kobalte/core/context-menu";
import type { PolymorphicProps } from "@kobalte/core/polymorphic";
import type {
ComponentProps,
ParentProps,
ValidComponent,
VoidProps,
} from "solid-js";
import { splitProps } from "solid-js";
export const ContextMenu = ContextMenuPrimitive;
export const ContextMenuTrigger = ContextMenuPrimitive.Trigger;
export const ContextMenuGroup = ContextMenuPrimitive.Group;
export const ContextMenuSub = ContextMenuPrimitive.Sub;
export const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup;
type contextMenuSubTriggerProps<T extends ValidComponent = "div"> = ParentProps<
ContextMenuSubTriggerProps<T> & {
class?: string;
inset?: boolean;
}
>;
export const ContextMenuSubTrigger = <T extends ValidComponent = "div">(
props: PolymorphicProps<T, contextMenuSubTriggerProps<T>>,
) => {
const [local, rest] = splitProps(props as contextMenuSubTriggerProps, [
"class",
"children",
"inset",
]);
return (
<ContextMenuPrimitive.SubTrigger
class={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[expanded]:bg-accent data-[expanded]:text-accent-foreground",
local.inset && "pl-8",
local.class,
)}
{...rest}
>
{local.children}
<svg
xmlns="http://www.w3.org/2000/svg"
class="ml-auto h-4 w-4"
viewBox="0 0 24 24"
>
<path
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="m9 6l6 6l-6 6"
/>
<title>Arrow</title>
</svg>
</ContextMenuPrimitive.SubTrigger>
);
};
type contextMenuSubContentProps<T extends ValidComponent = "div"> =
ContextMenuSubContentProps<T> & {
class?: string;
};
export const ContextMenuSubContent = <T extends ValidComponent = "div">(
props: PolymorphicProps<T, contextMenuSubContentProps<T>>,
) => {
const [local, rest] = splitProps(props as contextMenuSubContentProps, [
"class",
]);
return (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.SubContent
class={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[expanded]:animate-in data-[closed]:animate-out data-[closed]:fade-out-0 data-[expanded]:fade-in-0 data-[closed]:zoom-out-95 data-[expanded]:zoom-in-95",
local.class,
)}
{...rest}
/>
</ContextMenuPrimitive.Portal>
);
};
type contextMenuContentProps<T extends ValidComponent = "div"> =
ContextMenuContentProps<T> & {
class?: string;
};
export const ContextMenuContent = <T extends ValidComponent = "div">(
props: PolymorphicProps<T, contextMenuContentProps<T>>,
) => {
const [local, rest] = splitProps(props as contextMenuContentProps, ["class"]);
return (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
class={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md transition-shadow focus-visible:outline-none focus-visible:ring-[1.5px] focus-visible:ring-ring data-[expanded]:animate-in data-[closed]:animate-out data-[closed]:fade-out-0 data-[expanded]:fade-in-0 data-[closed]:zoom-out-95 data-[expanded]:zoom-in-95",
local.class,
)}
{...rest}
/>
</ContextMenuPrimitive.Portal>
);
};
type contextMenuItemProps<T extends ValidComponent = "div"> =
ContextMenuItemProps<T> & {
class?: string;
inset?: boolean;
};
export const ContextMenuItem = <T extends ValidComponent = "div">(
props: PolymorphicProps<T, contextMenuItemProps<T>>,
) => {
const [local, rest] = splitProps(props as contextMenuItemProps, [
"class",
"inset",
]);
return (
<ContextMenuPrimitive.Item
class={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
local.inset && "pl-8",
local.class,
)}
{...rest}
/>
);
};
type contextMenuCheckboxItemProps<T extends ValidComponent = "div"> =
ParentProps<
ContextMenuCheckboxItemProps<T> & {
class?: string;
}
>;
export const ContextMenuCheckboxItem = <T extends ValidComponent = "div">(
props: PolymorphicProps<T, contextMenuCheckboxItemProps<T>>,
) => {
const [local, rest] = splitProps(props as contextMenuCheckboxItemProps, [
"class",
"children",
]);
return (
<ContextMenuPrimitive.CheckboxItem
class={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
local.class,
)}
{...rest}
>
<ContextMenuPrimitive.ItemIndicator class="absolute left-2 inline-flex h-3.5 w-3.5 items-center justify-center">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
class="h-4 w-4"
>
<path
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="m5 12l5 5L20 7"
/>
<title>Checkbox</title>
</svg>
</ContextMenuPrimitive.ItemIndicator>
{local.children}
</ContextMenuPrimitive.CheckboxItem>
);
};
type contextMenuRadioItemProps<T extends ValidComponent = "div"> = ParentProps<
ContextMenuRadioItemProps<T> & {
class?: string;
}
>;
export const ContextMenuRadioItem = <T extends ValidComponent = "div">(
props: PolymorphicProps<T, contextMenuRadioItemProps<T>>,
) => {
const [local, rest] = splitProps(props as contextMenuRadioItemProps, [
"class",
"children",
]);
return (
<ContextMenuPrimitive.RadioItem
class={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
local.class,
)}
{...rest}
>
<ContextMenuPrimitive.ItemIndicator class="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
class="h-2 w-2"
>
<g
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
>
<path d="M0 0h24v24H0z" />
<path
fill="currentColor"
d="M7 3.34a10 10 0 1 1-4.995 8.984L2 12l.005-.324A10 10 0 0 1 7 3.34"
/>
</g>
<title>Radio</title>
</svg>
</ContextMenuPrimitive.ItemIndicator>
{local.children}
</ContextMenuPrimitive.RadioItem>
);
};
type contextMenuItemLabelProps<T extends ValidComponent = "div"> =
ContextMenuItemLabelProps<T> & {
class?: string;
inset?: boolean;
};
export const ContextMenuItemLabel = <T extends ValidComponent = "div">(
props: PolymorphicProps<T, contextMenuItemLabelProps<T>>,
) => {
const [local, rest] = splitProps(props as contextMenuItemLabelProps, [
"class",
"inset",
]);
return (
<ContextMenuPrimitive.ItemLabel
class={cn(
"px-2 py-1.5 text-sm font-semibold text-foreground",
local.inset && "pl-8",
local.class,
)}
{...rest}
/>
);
};
type contextMenuGroupLabelProps<T extends ValidComponent = "span"> =
ContextMenuGroupLabelProps<T> & {
class?: string;
inset?: boolean;
};
export const ContextMenuGroupLabel = <T extends ValidComponent = "span">(
props: PolymorphicProps<T, contextMenuGroupLabelProps<T>>,
) => {
const [local, rest] = splitProps(props as contextMenuGroupLabelProps, [
"class",
"inset",
]);
return (
<ContextMenuPrimitive.GroupLabel
as="div"
class={cn(
"px-2 py-1.5 text-sm font-semibold text-foreground",
local.inset && "pl-8",
local.class,
)}
{...rest}
/>
);
};
type contextMenuSeparatorProps<T extends ValidComponent = "hr"> = VoidProps<
ContextMenuSeparatorProps<T> & {
class?: string;
}
>;
export const ContextMenuSeparator = <T extends ValidComponent = "hr">(
props: PolymorphicProps<T, contextMenuSeparatorProps<T>>,
) => {
const [local, rest] = splitProps(props as contextMenuSeparatorProps, [
"class",
]);
return (
<ContextMenuPrimitive.Separator
class={cn("-mx-1 my-1 h-px bg-border", local.class)}
{...rest}
/>
);
};
export const ContextMenuShortcut = (props: ComponentProps<"span">) => {
const [local, rest] = splitProps(props, ["class"]);
return (
<span
class={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
local.class,
)}
{...rest}
/>
);
};

View File

@@ -0,0 +1,269 @@
import { cn } from "@/libs/cn";
import type {
DatePickerContentProps,
DatePickerInputProps,
DatePickerRangeTextProps,
DatePickerRootProps,
DatePickerTableCellProps,
DatePickerTableCellTriggerProps,
DatePickerTableHeaderProps,
DatePickerTableProps,
DatePickerTableRowProps,
DatePickerViewControlProps,
DatePickerViewProps,
DatePickerViewTriggerProps,
} from "@ark-ui/solid";
import { DatePicker as DatePickerPrimitive } from "@ark-ui/solid";
import type { VoidProps } from "solid-js";
import { splitProps } from "solid-js";
import { buttonVariants } from "./button";
export const DatePickerLabel = DatePickerPrimitive.Label;
export const DatePickerTableHead = DatePickerPrimitive.TableHead;
export const DatePickerTableBody = DatePickerPrimitive.TableBody;
export const DatePickerClearTrigger = DatePickerPrimitive.ClearTrigger;
export const DatePickerYearSelect = DatePickerPrimitive.YearSelect;
export const DatePickerMonthSelect = DatePickerPrimitive.MonthSelect;
export const DatePickerContext = DatePickerPrimitive.Context;
export const DatePickerRootProvider = DatePickerPrimitive.RootProvider;
export const DatePicker = (props: DatePickerRootProps) => {
return (
<DatePickerPrimitive.Root
format={(e) => {
const parsedDate = new Date(Date.parse(e.toString()));
const normalizedDate = new Date(
parsedDate.getUTCFullYear(),
parsedDate.getUTCMonth(),
parsedDate.getUTCDate(),
);
return new Intl.DateTimeFormat("en-US", {
dateStyle: "long",
}).format(normalizedDate);
}}
{...props}
/>
);
};
export const DatePickerView = (props: DatePickerViewProps) => {
const [local, rest] = splitProps(props, ["class"]);
return (
<DatePickerPrimitive.View class={cn("space-y-4", local.class)} {...rest} />
);
};
export const DatePickerViewControl = (props: DatePickerViewControlProps) => {
const [local, rest] = splitProps(props, ["class", "children"]);
return (
<DatePickerPrimitive.ViewControl
class={cn("flex items-center justify-between", local.class)}
{...rest}
>
<DatePickerPrimitive.PrevTrigger
class={cn(
buttonVariants({
variant: "outline",
}),
"h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100",
)}
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
viewBox="0 0 24 24"
>
<path
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="m15 6l-6 6l6 6"
/>
<title>Previous</title>
</svg>
</DatePickerPrimitive.PrevTrigger>
{local.children}
<DatePickerPrimitive.NextTrigger
class={cn(
buttonVariants({
variant: "outline",
}),
"h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100",
)}
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
viewBox="0 0 24 24"
>
<path
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="m9 6l6 6l-6 6"
/>
<title>Next</title>
</svg>
</DatePickerPrimitive.NextTrigger>
</DatePickerPrimitive.ViewControl>
);
};
export const DatePickerRangeText = (
props: VoidProps<DatePickerRangeTextProps>,
) => {
const [local, rest] = splitProps(props, ["class"]);
return (
<DatePickerPrimitive.RangeText
class={cn("text-sm font-medium", local.class)}
{...rest}
/>
);
};
export const DatePickerTable = (props: DatePickerTableProps) => {
const [local, rest] = splitProps(props, ["class"]);
return (
<DatePickerPrimitive.Table
class={cn("w-full border-collapse space-y-1", local.class)}
{...rest}
/>
);
};
export const DatePickerTableRow = (props: DatePickerTableRowProps) => {
const [local, rest] = splitProps(props, ["class"]);
return (
<DatePickerPrimitive.TableRow
class={cn("mt-2 flex w-full", local.class)}
{...rest}
/>
);
};
export const DatePickerTableHeader = (props: DatePickerTableHeaderProps) => {
const [local, rest] = splitProps(props, ["class"]);
return (
<DatePickerPrimitive.TableHeader
class={cn(
"w-8 flex-1 text-[0.8rem] font-normal text-muted-foreground",
local.class,
)}
{...rest}
/>
);
};
export const DatePickerTableCell = (props: DatePickerTableCellProps) => {
const [local, rest] = splitProps(props, ["class"]);
return (
<DatePickerPrimitive.TableCell
class={cn(
"flex-1 p-0 text-center text-sm",
"has-[[data-in-range]]:bg-accent has-[[data-in-range]]:first-of-type:rounded-l-md has-[[data-in-range]]:last-of-type:rounded-r-md",
"has-[[data-range-end]]:rounded-r-md has-[[data-range-start]]:rounded-l-md",
"has-[[data-outside-range][data-in-range]]:bg-accent/50",
local.class,
)}
{...rest}
/>
);
};
export const DatePickerTableCellTrigger = (
props: DatePickerTableCellTriggerProps,
) => {
const [local, rest] = splitProps(props, ["class"]);
return (
<DatePickerPrimitive.TableCellTrigger
class={cn(
buttonVariants({ variant: "ghost" }),
"size-8 w-full p-0 font-normal data-[selected]:opacity-100",
"data-[today]:bg-accent data-[today]:text-accent-foreground",
"[&:is([data-today][data-selected])]:bg-primary [&:is([data-today][data-selected])]:text-primary-foreground",
"data-[selected]:bg-primary data-[selected]:text-primary-foreground data-[selected]:hover:bg-primary data-[selected]:hover:text-primary-foreground",
"data-[disabled]:text-muted-foreground data-[disabled]:opacity-50",
"data-[outside-range]:text-muted-foreground data-[outside-range]:opacity-50",
"[&:is([data-outside-range][data-in-range])]:bg-accent/50 [&:is([data-outside-range][data-in-range])]:text-muted-foreground [&:is([data-outside-range][data-in-range])]:opacity-30",
local.class,
)}
{...rest}
/>
);
};
export const DatePickerViewTrigger = (props: DatePickerViewTriggerProps) => {
const [local, rest] = splitProps(props, ["class"]);
return (
<DatePickerPrimitive.ViewTrigger
class={cn(buttonVariants({ variant: "ghost" }), "h-7", local.class)}
{...rest}
/>
);
};
export const DatePickerContent = (props: DatePickerContentProps) => {
const [local, rest] = splitProps(props, ["class", "children"]);
return (
<DatePickerPrimitive.Positioner>
<DatePickerPrimitive.Content
class={cn(
"rounded-md border bg-popover p-3 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 z-50",
local.class,
)}
{...rest}
>
{local.children}
</DatePickerPrimitive.Content>
</DatePickerPrimitive.Positioner>
);
};
export const DatePickerInput = (props: DatePickerInputProps) => {
const [local, rest] = splitProps(props, ["class", "children"]);
return (
<DatePickerPrimitive.Control class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-[1.5px] focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50">
<DatePickerPrimitive.Input
class={cn(
"w-full appearance-none bg-transparent outline-none",
local.class,
)}
{...rest}
/>
<DatePickerPrimitive.Trigger class="transition-shadow focus-visible:outline-none focus-visible:ring-[1.5px] focus-visible:ring-ring">
<svg
xmlns="http://www.w3.org/2000/svg"
class="mx-1 h-4 w-4"
viewBox="0 0 24 24"
>
<path
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 7a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2zm12-4v4M8 3v4m-4 4h16m-9 4h1m0 0v3"
/>
<title>Calendar</title>
</svg>
</DatePickerPrimitive.Trigger>
</DatePickerPrimitive.Control>
);
};

View File

@@ -0,0 +1,128 @@
import { cn } from "@/libs/cn";
import type {
DialogContentProps,
DialogDescriptionProps,
DialogTitleProps,
} from "@kobalte/core/dialog";
import { Dialog as DialogPrimitive } from "@kobalte/core/dialog";
import type { PolymorphicProps } from "@kobalte/core/polymorphic";
import type { ComponentProps, ParentProps, ValidComponent } from "solid-js";
import { splitProps } from "solid-js";
export const Dialog = DialogPrimitive;
export const DialogTrigger = DialogPrimitive.Trigger;
type dialogContentProps<T extends ValidComponent = "div"> = ParentProps<
DialogContentProps<T> & {
class?: string;
}
>;
export const DialogContent = <T extends ValidComponent = "div">(
props: PolymorphicProps<T, dialogContentProps<T>>,
) => {
const [local, rest] = splitProps(props as dialogContentProps, [
"class",
"children",
]);
return (
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay
class={cn(
"fixed inset-0 z-50 bg-background/80 data-[expanded]:animate-in data-[closed]:animate-out data-[closed]:fade-out-0 data-[expanded]:fade-in-0",
)}
{...rest}
/>
<DialogPrimitive.Content
class={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg data-[closed]:duration-200 data-[expanded]:duration-200 data-[expanded]:animate-in data-[closed]:animate-out data-[closed]:fade-out-0 data-[expanded]:fade-in-0 data-[closed]:zoom-out-95 data-[expanded]:zoom-in-95 data-[closed]:slide-out-to-left-1/2 data-[closed]:slide-out-to-top-[48%] data-[expanded]:slide-in-from-left-1/2 data-[expanded]:slide-in-from-top-[48%] sm:rounded-lg md:w-full",
local.class,
)}
{...rest}
>
{local.children}
<DialogPrimitive.CloseButton class="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-[opacity,box-shadow] hover:opacity-100 focus:outline-none focus:ring-[1.5px] focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
class="h-4 w-4"
>
<path
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M18 6L6 18M6 6l12 12"
/>
<title>Close</title>
</svg>
</DialogPrimitive.CloseButton>
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
);
};
type dialogTitleProps<T extends ValidComponent = "h2"> = DialogTitleProps<T> & {
class?: string;
};
export const DialogTitle = <T extends ValidComponent = "h2">(
props: PolymorphicProps<T, dialogTitleProps<T>>,
) => {
const [local, rest] = splitProps(props as dialogTitleProps, ["class"]);
return (
<DialogPrimitive.Title
class={cn("text-lg font-semibold text-foreground", local.class)}
{...rest}
/>
);
};
type dialogDescriptionProps<T extends ValidComponent = "p"> =
DialogDescriptionProps<T> & {
class?: string;
};
export const DialogDescription = <T extends ValidComponent = "p">(
props: PolymorphicProps<T, dialogDescriptionProps<T>>,
) => {
const [local, rest] = splitProps(props as dialogDescriptionProps, ["class"]);
return (
<DialogPrimitive.Description
class={cn("text-sm text-muted-foreground", local.class)}
{...rest}
/>
);
};
export const DialogHeader = (props: ComponentProps<"div">) => {
const [local, rest] = splitProps(props, ["class"]);
return (
<div
class={cn(
"flex flex-col space-y-2 text-center sm:text-left",
local.class,
)}
{...rest}
/>
);
};
export const DialogFooter = (props: ComponentProps<"div">) => {
const [local, rest] = splitProps(props, ["class"]);
return (
<div
class={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
local.class,
)}
{...rest}
/>
);
};

View File

@@ -0,0 +1,107 @@
import { cn } from "@/libs/cn";
import type {
ContentProps,
DescriptionProps,
DynamicProps,
LabelProps,
} from "@corvu/drawer";
import DrawerPrimitive from "@corvu/drawer";
import type { ComponentProps, ParentProps, ValidComponent } from "solid-js";
import { splitProps } from "solid-js";
export const Drawer = DrawerPrimitive;
export const DrawerTrigger = DrawerPrimitive.Trigger;
export const DrawerClose = DrawerPrimitive.Close;
type drawerContentProps<T extends ValidComponent = "div"> = ParentProps<
ContentProps<T> & {
class?: string;
}
>;
export const DrawerContent = <T extends ValidComponent = "div">(
props: DynamicProps<T, drawerContentProps<T>>,
) => {
const [local, rest] = splitProps(props as drawerContentProps, [
"class",
"children",
]);
const ctx = DrawerPrimitive.useContext();
return (
<DrawerPrimitive.Portal>
<DrawerPrimitive.Overlay
class="fixed inset-0 z-50 data-[transitioning]:transition-colors data-[transitioning]:duration-200"
style={{
"background-color": `hsl(var(--background) / ${0.8 * ctx.openPercentage()})`,
}}
/>
<DrawerPrimitive.Content
class={cn(
"fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-xl border bg-background after:absolute after:inset-x-0 after:top-full after:h-[50%] after:bg-inherit data-[transitioning]:transition-transform data-[transitioning]:duration-200 md:select-none",
local.class,
)}
{...rest}
>
<div class="mx-auto mt-4 h-2 w-[100px] rounded-full bg-muted" />
{local.children}
</DrawerPrimitive.Content>
</DrawerPrimitive.Portal>
);
};
export const DrawerHeader = (props: ComponentProps<"div">) => {
const [local, rest] = splitProps(props, ["class"]);
return (
<div
class={cn("grid gap-1.5 p-4 text-center sm:text-left", local.class)}
{...rest}
/>
);
};
export const DrawerFooter = (props: ComponentProps<"div">) => {
const [local, rest] = splitProps(props, ["class"]);
return (
<div class={cn("mt-auto flex flex-col gap-2 p-4", local.class)} {...rest} />
);
};
type DrawerLabelProps = LabelProps & {
class?: string;
};
export const DrawerLabel = <T extends ValidComponent = "h2">(
props: DynamicProps<T, DrawerLabelProps>,
) => {
const [local, rest] = splitProps(props as DrawerLabelProps, ["class"]);
return (
<DrawerPrimitive.Label
class={cn(
"text-lg font-semibold leading-none tracking-tight",
local.class,
)}
{...rest}
/>
);
};
type DrawerDescriptionProps = DescriptionProps & {
class?: string;
};
export const DrawerDescription = <T extends ValidComponent = "p">(
props: DynamicProps<T, DrawerDescriptionProps>,
) => {
const [local, rest] = splitProps(props as DrawerDescriptionProps, ["class"]);
return (
<DrawerPrimitive.Description
class={cn("text-sm text-muted-foreground", local.class)}
{...rest}
/>
);
};

View File

@@ -0,0 +1,314 @@
import { cn } from "@/libs/cn";
import type {
DropdownMenuCheckboxItemProps,
DropdownMenuContentProps,
DropdownMenuGroupLabelProps,
DropdownMenuItemLabelProps,
DropdownMenuItemProps,
DropdownMenuRadioItemProps,
DropdownMenuRootProps,
DropdownMenuSeparatorProps,
DropdownMenuSubTriggerProps,
} from "@kobalte/core/dropdown-menu";
import { DropdownMenu as DropdownMenuPrimitive } from "@kobalte/core/dropdown-menu";
import type { PolymorphicProps } from "@kobalte/core/polymorphic";
import type { ComponentProps, ParentProps, ValidComponent } from "solid-js";
import { mergeProps, splitProps } from "solid-js";
export const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
export const DropdownMenuGroup = DropdownMenuPrimitive.Group;
export const DropdownMenuSub = DropdownMenuPrimitive.Sub;
export const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
export const DropdownMenu = (props: DropdownMenuRootProps) => {
const merge = mergeProps<DropdownMenuRootProps[]>({ gutter: 4 }, props);
return <DropdownMenuPrimitive {...merge} />;
};
type dropdownMenuContentProps<T extends ValidComponent = "div"> =
DropdownMenuContentProps<T> & {
class?: string;
};
export const DropdownMenuContent = <T extends ValidComponent = "div">(
props: PolymorphicProps<T, dropdownMenuContentProps<T>>,
) => {
const [local, rest] = splitProps(props as dropdownMenuContentProps, [
"class",
]);
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
class={cn(
"min-w-8rem z-50 overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md transition-shadow focus-visible:outline-none focus-visible:ring-[1.5px] focus-visible:ring-ring data-[expanded]:animate-in data-[closed]:animate-out data-[closed]:fade-out-0 data-[expanded]:fade-in-0 data-[closed]:zoom-out-95 data-[expanded]:zoom-in-95",
local.class,
)}
{...rest}
/>
</DropdownMenuPrimitive.Portal>
);
};
type dropdownMenuItemProps<T extends ValidComponent = "div"> =
DropdownMenuItemProps<T> & {
class?: string;
inset?: boolean;
};
export const DropdownMenuItem = <T extends ValidComponent = "div">(
props: PolymorphicProps<T, dropdownMenuItemProps<T>>,
) => {
const [local, rest] = splitProps(props as dropdownMenuItemProps, [
"class",
"inset",
]);
return (
<DropdownMenuPrimitive.Item
class={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
local.inset && "pl-8",
local.class,
)}
{...rest}
/>
);
};
type dropdownMenuGroupLabelProps<T extends ValidComponent = "span"> =
DropdownMenuGroupLabelProps<T> & {
class?: string;
};
export const DropdownMenuGroupLabel = <T extends ValidComponent = "span">(
props: PolymorphicProps<T, dropdownMenuGroupLabelProps<T>>,
) => {
const [local, rest] = splitProps(props as dropdownMenuGroupLabelProps, [
"class",
]);
return (
<DropdownMenuPrimitive.GroupLabel
as="div"
class={cn("px-2 py-1.5 text-sm font-semibold", local.class)}
{...rest}
/>
);
};
type dropdownMenuItemLabelProps<T extends ValidComponent = "div"> =
DropdownMenuItemLabelProps<T> & {
class?: string;
};
export const DropdownMenuItemLabel = <T extends ValidComponent = "div">(
props: PolymorphicProps<T, dropdownMenuItemLabelProps<T>>,
) => {
const [local, rest] = splitProps(props as dropdownMenuItemLabelProps, [
"class",
]);
return (
<DropdownMenuPrimitive.ItemLabel
as="div"
class={cn("px-2 py-1.5 text-sm font-semibold", local.class)}
{...rest}
/>
);
};
type dropdownMenuSeparatorProps<T extends ValidComponent = "hr"> =
DropdownMenuSeparatorProps<T> & {
class?: string;
};
export const DropdownMenuSeparator = <T extends ValidComponent = "hr">(
props: PolymorphicProps<T, dropdownMenuSeparatorProps<T>>,
) => {
const [local, rest] = splitProps(props as dropdownMenuSeparatorProps, [
"class",
]);
return (
<DropdownMenuPrimitive.Separator
class={cn("-mx-1 my-1 h-px bg-muted", local.class)}
{...rest}
/>
);
};
export const DropdownMenuShortcut = (props: ComponentProps<"span">) => {
const [local, rest] = splitProps(props, ["class"]);
return (
<span
class={cn("ml-auto text-xs tracking-widest opacity-60", local.class)}
{...rest}
/>
);
};
type dropdownMenuSubTriggerProps<T extends ValidComponent = "div"> =
ParentProps<
DropdownMenuSubTriggerProps<T> & {
class?: string;
}
>;
export const DropdownMenuSubTrigger = <T extends ValidComponent = "div">(
props: PolymorphicProps<T, dropdownMenuSubTriggerProps<T>>,
) => {
const [local, rest] = splitProps(props as dropdownMenuSubTriggerProps, [
"class",
"children",
]);
return (
<DropdownMenuPrimitive.SubTrigger
class={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[expanded]:bg-accent",
local.class,
)}
{...rest}
>
{local.children}
<svg
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
viewBox="0 0 24 24"
class="ml-auto h-4 w-4"
>
<path
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="m9 6l6 6l-6 6"
/>
<title>Arrow</title>
</svg>
</DropdownMenuPrimitive.SubTrigger>
);
};
type dropdownMenuSubContentProps<T extends ValidComponent = "div"> =
DropdownMenuSubTriggerProps<T> & {
class?: string;
};
export const DropdownMenuSubContent = <T extends ValidComponent = "div">(
props: PolymorphicProps<T, dropdownMenuSubContentProps<T>>,
) => {
const [local, rest] = splitProps(props as dropdownMenuSubContentProps, [
"class",
]);
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.SubContent
class={cn(
"min-w-8rem z-50 overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[expanded]:animate-in data-[closed]:animate-out data-[closed]:fade-out-0 data-[expanded]:fade-in-0 data-[closed]:zoom-out-95 data-[expanded]:zoom-in-95",
local.class,
)}
{...rest}
/>
</DropdownMenuPrimitive.Portal>
);
};
type dropdownMenuCheckboxItemProps<T extends ValidComponent = "div"> =
ParentProps<
DropdownMenuCheckboxItemProps<T> & {
class?: string;
}
>;
export const DropdownMenuCheckboxItem = <T extends ValidComponent = "div">(
props: PolymorphicProps<T, dropdownMenuCheckboxItemProps<T>>,
) => {
const [local, rest] = splitProps(props as dropdownMenuCheckboxItemProps, [
"class",
"children",
]);
return (
<DropdownMenuPrimitive.CheckboxItem
class={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
local.class,
)}
{...rest}
>
<DropdownMenuPrimitive.ItemIndicator class="absolute left-2 inline-flex h-4 w-4 items-center justify-center">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
class="h-4 w-4"
>
<path
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="m5 12l5 5L20 7"
/>
<title>Checkbox</title>
</svg>
</DropdownMenuPrimitive.ItemIndicator>
{props.children}
</DropdownMenuPrimitive.CheckboxItem>
);
};
type dropdownMenuRadioItemProps<T extends ValidComponent = "div"> = ParentProps<
DropdownMenuRadioItemProps<T> & {
class?: string;
}
>;
export const DropdownMenuRadioItem = <T extends ValidComponent = "div">(
props: PolymorphicProps<T, dropdownMenuRadioItemProps<T>>,
) => {
const [local, rest] = splitProps(props as dropdownMenuRadioItemProps, [
"class",
"children",
]);
return (
<DropdownMenuPrimitive.RadioItem
class={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
local.class,
)}
{...rest}
>
<DropdownMenuPrimitive.ItemIndicator class="absolute left-2 inline-flex h-4 w-4 items-center justify-center">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
class="h-2 w-2"
>
<g
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
>
<path d="M0 0h24v24H0z" />
<path
fill="currentColor"
d="M7 3.34a10 10 0 1 1-4.995 8.984L2 12l.005-.324A10 10 0 0 1 7 3.34"
/>
</g>
<title>Radio</title>
</svg>
</DropdownMenuPrimitive.ItemIndicator>
{props.children}
</DropdownMenuPrimitive.RadioItem>
);
};

View File

@@ -0,0 +1,32 @@
import { cn } from "@/libs/cn";
import type { HoverCardContentProps } from "@kobalte/core/hover-card";
import { HoverCard as HoverCardPrimitive } from "@kobalte/core/hover-card";
import type { PolymorphicProps } from "@kobalte/core/polymorphic";
import type { ValidComponent } from "solid-js";
import { splitProps } from "solid-js";
export const HoverCard = HoverCardPrimitive;
export const HoverCardTrigger = HoverCardPrimitive.Trigger;
type hoverCardContentProps<T extends ValidComponent = "div"> =
HoverCardContentProps<T> & {
class?: string;
};
export const HoverCardContent = <T extends ValidComponent = "div">(
props: PolymorphicProps<T, hoverCardContentProps<T>>,
) => {
const [local, rest] = splitProps(props as hoverCardContentProps, ["class"]);
return (
<HoverCardPrimitive.Portal>
<HoverCardPrimitive.Content
class={cn(
"z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[expanded]:animate-in data-[closed]:animate-out data-[closed]:fade-out-0 data-[expanded]:fade-in-0 data-[closed]:zoom-out-95 data-[expanded]:zoom-in-95",
local.class,
)}
{...rest}
/>
</HoverCardPrimitive.Portal>
);
};

View File

@@ -0,0 +1,68 @@
import { cn } from "@/libs/cn";
import type {
ImageFallbackProps,
ImageImgProps,
ImageRootProps,
} from "@kobalte/core/image";
import { Image as ImagePrimitive } from "@kobalte/core/image";
import type { PolymorphicProps } from "@kobalte/core/polymorphic";
import type { ValidComponent } from "solid-js";
import { splitProps } from "solid-js";
type imageRootProps<T extends ValidComponent = "span"> = ImageRootProps<T> & {
class?: string;
};
export const ImageRoot = <T extends ValidComponent = "span">(
props: PolymorphicProps<T, imageRootProps<T>>,
) => {
const [local, rest] = splitProps(props as imageRootProps, ["class"]);
return (
<ImagePrimitive
class={cn(
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
local.class,
)}
{...rest}
/>
);
};
type imageProps<T extends ValidComponent = "img"> = ImageImgProps<T> & {
class?: string;
};
export const Image = <T extends ValidComponent = "img">(
props: PolymorphicProps<T, imageProps<T>>,
) => {
const [local, rest] = splitProps(props as imageProps, ["class"]);
return (
<ImagePrimitive.Img
class={cn("aspect-square h-full w-full", local.class)}
{...rest}
/>
);
};
type imageFallbackProps<T extends ValidComponent = "span"> =
ImageFallbackProps<T> & {
class?: string;
};
export const ImageFallback = <T extends ValidComponent = "span">(
props: PolymorphicProps<T, imageFallbackProps<T>>,
) => {
const [local, rest] = splitProps(props as imageFallbackProps, ["class"]);
return (
<ImagePrimitive.Fallback
class={cn(
"flex h-full w-full items-center justify-center rounded-full bg-muted",
local.class,
)}
{...rest}
/>
);
};

View File

@@ -0,0 +1,349 @@
import { cn } from "@/libs/cn";
import type {
MenubarCheckboxItemProps,
MenubarContentProps,
MenubarItemLabelProps,
MenubarItemProps,
MenubarMenuProps,
MenubarRadioItemProps,
MenubarRootProps,
MenubarSeparatorProps,
MenubarSubContentProps,
MenubarSubTriggerProps,
MenubarTriggerProps,
} from "@kobalte/core/menubar";
import { Menubar as MenubarPrimitive } from "@kobalte/core/menubar";
import type { PolymorphicProps } from "@kobalte/core/polymorphic";
import type { ComponentProps, ParentProps, ValidComponent } from "solid-js";
import { mergeProps, splitProps } from "solid-js";
export const MenubarSub = MenubarPrimitive.Sub;
export const MenubarRadioGroup = MenubarPrimitive.RadioGroup;
type menubarProps<T extends ValidComponent = "div"> = MenubarRootProps<T> & {
class?: string;
};
export const Menubar = <T extends ValidComponent = "div">(
props: PolymorphicProps<T, menubarProps<T>>,
) => {
const [local, rest] = splitProps(props as menubarProps, ["class"]);
return (
<MenubarPrimitive
class={cn(
"flex h-9 items-center space-x-1 rounded-md border bg-background p-1 shadow-sm",
local.class,
)}
{...rest}
/>
);
};
export const MenubarMenu = (props: MenubarMenuProps) => {
const merge = mergeProps<MenubarMenuProps[]>({ gutter: 8, shift: -4 }, props);
return <MenubarPrimitive.Menu {...merge} />;
};
type menubarTriggerProps<T extends ValidComponent = "button"> =
MenubarTriggerProps<T> & {
class?: string;
};
export const MenubarTrigger = <T extends ValidComponent = "button">(
props: PolymorphicProps<T, menubarTriggerProps<T>>,
) => {
const [local, rest] = splitProps(props as menubarTriggerProps, ["class"]);
return (
<MenubarPrimitive.Trigger
class={cn(
"flex cursor-default select-none items-center rounded-sm px-3 py-1 text-sm font-medium outline-none focus:bg-accent focus:text-accent-foreground data-[expanded]:bg-accent data-[expanded]:text-accent-foreground",
local.class,
)}
{...rest}
/>
);
};
type menubarSubTriggerProps<T extends ValidComponent = "button"> = ParentProps<
MenubarSubTriggerProps<T> & {
class?: string;
inset?: boolean;
}
>;
export const MenubarSubTrigger = <T extends ValidComponent = "button">(
props: PolymorphicProps<T, menubarSubTriggerProps<T>>,
) => {
const [local, rest] = splitProps(props as menubarSubTriggerProps, [
"class",
"children",
"inset",
]);
return (
<MenubarPrimitive.SubTrigger
class={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[expanded]:bg-accent data-[expanded]:text-accent-foreground",
local.inset && "pl-8",
local.class,
)}
{...rest}
>
{local.children}
<svg
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
viewBox="0 0 24 24"
class="ml-auto h-4 w-4"
>
<path
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="m9 6l6 6l-6 6"
/>
<title>Arrow</title>
</svg>
</MenubarPrimitive.SubTrigger>
);
};
type menubarSubContentProps<T extends ValidComponent = "div"> = ParentProps<
MenubarSubContentProps<T> & {
class?: string;
}
>;
export const MenubarSubContent = <T extends ValidComponent = "div">(
props: PolymorphicProps<T, menubarSubContentProps<T>>,
) => {
const [local, rest] = splitProps(props as menubarSubContentProps, [
"class",
"children",
]);
return (
<MenubarPrimitive.Portal>
<MenubarPrimitive.SubContent
class={cn(
"z-50 min-w-[8rem] origin-[--kb-menu-content-transform-origin] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg outline-none data-[expanded]:animate-in data-[closed]:animate-out data-[closed]:fade-out-0 data-[expanded]:fade-in-0 data-[closed]:zoom-out-95 data-[expanded]:zoom-in-95",
local.class,
)}
{...rest}
>
{local.children}
</MenubarPrimitive.SubContent>
</MenubarPrimitive.Portal>
);
};
type menubarContentProps<T extends ValidComponent = "div"> = ParentProps<
MenubarContentProps<T> & {
class?: string;
}
>;
export const MenubarContent = <T extends ValidComponent = "div">(
props: PolymorphicProps<T, menubarContentProps<T>>,
) => {
const [local, rest] = splitProps(props as menubarContentProps, [
"class",
"children",
]);
return (
<MenubarPrimitive.Portal>
<MenubarPrimitive.Content
class={cn(
"z-50 min-w-[12rem] origin-[--kb-menu-content-transform-origin] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md outline-none data-[expanded]:animate-in data-[closed]:fade-out-0 data-[expanded]:fade-in-0 data-[closed]:zoom-out-95 data-[expanded]:zoom-in-95",
local.class,
)}
{...rest}
>
{local.children}
</MenubarPrimitive.Content>
</MenubarPrimitive.Portal>
);
};
type menubarItemProps<T extends ValidComponent = "div"> =
MenubarItemProps<T> & {
class?: string;
inset?: boolean;
};
export const MenubarItem = <T extends ValidComponent = "div">(
props: PolymorphicProps<T, menubarItemProps<T>>,
) => {
const [local, rest] = splitProps(props as menubarItemProps, [
"class",
"inset",
]);
return (
<MenubarPrimitive.Item
class={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
local.inset && "pl-8",
local.class,
)}
{...rest}
/>
);
};
type menubarItemLabelProps<T extends ValidComponent = "div"> =
MenubarItemLabelProps<T> & {
class?: string;
inset?: boolean;
};
export const MenubarItemLabel = <T extends ValidComponent = "div">(
props: PolymorphicProps<T, menubarItemLabelProps<T>>,
) => {
const [local, rest] = splitProps(props as menubarItemLabelProps, [
"class",
"inset",
]);
return (
<MenubarPrimitive.ItemLabel
class={cn(
"px-2 py-1.5 text-sm font-semibold",
local.inset && "pl-8",
local.class,
)}
{...rest}
/>
);
};
type menubarSeparatorProps<T extends ValidComponent = "hr"> =
MenubarSeparatorProps<T> & {
class?: string;
};
export const MenubarSeparator = <T extends ValidComponent = "hr">(
props: PolymorphicProps<T, menubarSeparatorProps<T>>,
) => {
const [local, rest] = splitProps(props as menubarSeparatorProps, ["class"]);
return (
<MenubarPrimitive.Separator
class={cn("-mx-1 my-1 h-px bg-muted", local.class)}
{...rest}
/>
);
};
type menubarCheckboxItemProps<T extends ValidComponent = "div"> = ParentProps<
MenubarCheckboxItemProps<T> & {
class?: string;
}
>;
export const MenubarCheckboxItem = <T extends ValidComponent = "div">(
props: PolymorphicProps<T, menubarCheckboxItemProps<T>>,
) => {
const [local, rest] = splitProps(props as menubarCheckboxItemProps, [
"class",
"children",
]);
return (
<MenubarPrimitive.CheckboxItem
class={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
local.class,
)}
{...rest}
>
<MenubarPrimitive.ItemIndicator class="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
class="h-4 w-4"
>
<path
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="m5 12l5 5L20 7"
/>
<title>Checkbox</title>
</svg>
</MenubarPrimitive.ItemIndicator>
{local.children}
</MenubarPrimitive.CheckboxItem>
);
};
type menubarRadioItemProps<T extends ValidComponent = "div"> = ParentProps<
MenubarRadioItemProps<T> & {
class?: string;
}
>;
export const MenubarRadioItem = <T extends ValidComponent = "div">(
props: PolymorphicProps<T, menubarRadioItemProps<T>>,
) => {
const [local, rest] = splitProps(props as menubarRadioItemProps, [
"class",
"children",
]);
return (
<MenubarPrimitive.RadioItem
class={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
local.class,
)}
{...rest}
>
<MenubarPrimitive.ItemIndicator class="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
class="h-2 w-2"
>
<g
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
>
<path d="M0 0h24v24H0z" />
<path
fill="currentColor"
d="M7 3.34a10 10 0 1 1-4.995 8.984L2 12l.005-.324A10 10 0 0 1 7 3.34"
/>
</g>
<title>Radio</title>
</svg>
</MenubarPrimitive.ItemIndicator>
{local.children}
</MenubarPrimitive.RadioItem>
);
};
export const MenubarShortcut = (props: ComponentProps<"span">) => {
const [local, rest] = splitProps(props, ["class"]);
return (
<span
class={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
local.class,
)}
{...rest}
/>
);
};

View File

@@ -0,0 +1,168 @@
import { cn } from "@/libs/cn";
import type {
NavigationMenuContentProps,
NavigationMenuRootProps,
NavigationMenuTriggerProps,
} from "@kobalte/core/navigation-menu";
import { NavigationMenu as NavigationMenuPrimitive } from "@kobalte/core/navigation-menu";
import type { PolymorphicProps } from "@kobalte/core/polymorphic";
import {
type ParentProps,
Show,
type ValidComponent,
mergeProps,
splitProps,
} from "solid-js";
export const NavigationMenuItem = NavigationMenuPrimitive.Menu;
export const NavigationMenuLink = NavigationMenuPrimitive.Item;
export const NavigationMenuItemLabel = NavigationMenuPrimitive.ItemLabel;
export const NavigationMenuDescription =
NavigationMenuPrimitive.ItemDescription;
export const NavigationMenuItemIndicator =
NavigationMenuPrimitive.ItemIndicator;
export const NavigationMenuSub = NavigationMenuPrimitive.Sub;
export const NavigationMenuSubTrigger = NavigationMenuPrimitive.SubTrigger;
export const NavigationMenuSubContent = NavigationMenuPrimitive.SubContent;
export const NavigationMenuRadioGroup = NavigationMenuPrimitive.RadioGroup;
export const NavigationMenuRadioItem = NavigationMenuPrimitive.RadioItem;
export const NavigationMenuCheckboxItem = NavigationMenuPrimitive.CheckboxItem;
export const NavigationMenuSeparator = NavigationMenuPrimitive.Separator;
type withArrow = {
withArrow?: boolean;
};
type navigationMenuProps<T extends ValidComponent = "ul"> = ParentProps<
NavigationMenuRootProps<T> &
withArrow & {
class?: string;
}
>;
export const NavigationMenu = <T extends ValidComponent = "ul">(
props: PolymorphicProps<T, navigationMenuProps<T>>,
) => {
const merge = mergeProps<navigationMenuProps<T>[]>(
{
get gutter() {
return props.withArrow ? props.gutter : 6;
},
withArrow: false,
},
props,
);
const [local, rest] = splitProps(merge as navigationMenuProps, [
"class",
"children",
"withArrow",
]);
return (
<NavigationMenuPrimitive
class={cn("flex w-max items-center justify-center gap-x-1", local.class)}
{...rest}
>
{local.children}
<NavigationMenuPrimitive.Viewport
class={cn(
"pointer-events-none z-50 overflow-x-clip overflow-y-visible rounded-md border bg-popover text-popover-foreground shadow",
"h-[--kb-navigation-menu-viewport-height] w-[--kb-navigation-menu-viewport-width] transition-[width,height] duration-300",
"origin-[--kb-menu-content-transform-origin]",
"data-[expanded]:duration-300 data-[expanded]:animate-in data-[expanded]:fade-in data-[expanded]:zoom-in-95",
"data-[closed]:duration-300 data-[closed]:animate-out data-[closed]:fade-out data-[closed]:zoom-out-95",
)}
>
<Show when={local.withArrow}>
<NavigationMenuPrimitive.Arrow class="transition-transform duration-300" />
</Show>
</NavigationMenuPrimitive.Viewport>
</NavigationMenuPrimitive>
);
};
type navigationMenuTriggerProps<T extends ValidComponent = "button"> =
ParentProps<
NavigationMenuTriggerProps<T> &
withArrow & {
class?: string;
}
>;
export const NavigationMenuTrigger = <T extends ValidComponent = "button">(
props: PolymorphicProps<T, navigationMenuTriggerProps<T>>,
) => {
const merge = mergeProps<navigationMenuTriggerProps<T>[]>(
{
get withArrow() {
return props.as === undefined ? true : props.withArrow;
},
},
props,
);
const [local, rest] = splitProps(merge as navigationMenuTriggerProps, [
"class",
"children",
"withArrow",
]);
return (
<NavigationMenuPrimitive.Trigger
class={cn(
"inline-flex w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium outline-none transition-colors duration-300 hover:bg-accent hover:text-accent-foreground disabled:pointer-events-none disabled:opacity-50",
local.class,
)}
{...rest}
>
{local.children}
<Show when={local.withArrow}>
<NavigationMenuPrimitive.Icon
class="ml-1 size-3 transition-transform duration-300 data-[expanded]:rotate-180"
as="svg"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
>
<path
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="m6 9l6 6l6-6"
/>
</NavigationMenuPrimitive.Icon>
</Show>
</NavigationMenuPrimitive.Trigger>
);
};
type navigationMenuContentProps<T extends ValidComponent = "ul"> = ParentProps<
NavigationMenuContentProps<T> & {
class?: string;
}
>;
export const NavigationMenuContent = <T extends ValidComponent = "ul">(
props: PolymorphicProps<T, navigationMenuContentProps<T>>,
) => {
const [local, rest] = splitProps(props as navigationMenuContentProps, [
"class",
"children",
]);
return (
<NavigationMenuPrimitive.Portal>
<NavigationMenuPrimitive.Content
class={cn(
"absolute left-0 top-0 p-4 outline-none",
"data-[motion^=from-]:duration-300 data-[motion^=from-]:animate-in data-[motion^=from-]:fade-in data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52",
"data-[motion^=to-]:duration-300 data-[motion^=to-]:animate-out data-[motion^=to-]:fade-out data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52",
local.class,
)}
{...rest}
>
{local.children}
</NavigationMenuPrimitive.Content>
</NavigationMenuPrimitive.Portal>
);
};

View File

@@ -0,0 +1,214 @@
import { cn } from "@/libs/cn";
import type {
NumberFieldDecrementTriggerProps,
NumberFieldDescriptionProps,
NumberFieldErrorMessageProps,
NumberFieldIncrementTriggerProps,
NumberFieldInputProps,
NumberFieldLabelProps,
NumberFieldRootProps,
} from "@kobalte/core/number-field";
import { NumberField as NumberFieldPrimitive } from "@kobalte/core/number-field";
import type { PolymorphicProps } from "@kobalte/core/polymorphic";
import type { ComponentProps, ValidComponent, VoidProps } from "solid-js";
import { splitProps } from "solid-js";
import { textfieldLabel } from "./textfield";
export const NumberFieldHiddenInput = NumberFieldPrimitive.HiddenInput;
type numberFieldLabelProps<T extends ValidComponent = "div"> =
NumberFieldLabelProps<T> & {
class?: string;
};
export const NumberFieldLabel = <T extends ValidComponent = "div">(
props: PolymorphicProps<T, numberFieldLabelProps<T>>,
) => {
const [local, rest] = splitProps(props as numberFieldLabelProps, ["class"]);
return (
<NumberFieldPrimitive.Label
class={cn(textfieldLabel({ label: true }), local.class)}
{...rest}
/>
);
};
type numberFieldDescriptionProps<T extends ValidComponent = "div"> =
NumberFieldDescriptionProps<T> & {
class?: string;
};
export const NumberFieldDescription = <T extends ValidComponent = "div">(
props: PolymorphicProps<T, numberFieldDescriptionProps<T>>,
) => {
const [local, rest] = splitProps(props as numberFieldDescriptionProps, [
"class",
]);
return (
<NumberFieldPrimitive.Description
class={cn(
textfieldLabel({ description: true, label: false }),
local.class,
)}
{...rest}
/>
);
};
type numberFieldErrorMessageProps<T extends ValidComponent = "div"> =
NumberFieldErrorMessageProps<T> & {
class?: string;
};
export const NumberFieldErrorMessage = <T extends ValidComponent = "div">(
props: PolymorphicProps<T, numberFieldErrorMessageProps<T>>,
) => {
const [local, rest] = splitProps(props as numberFieldErrorMessageProps, [
"class",
]);
return (
<NumberFieldPrimitive.ErrorMessage
class={cn(textfieldLabel({ error: true }), local.class)}
{...rest}
/>
);
};
type numberFieldProps<T extends ValidComponent = "div"> =
NumberFieldRootProps<T> & {
class?: string;
};
export const NumberField = <T extends ValidComponent = "div">(
props: PolymorphicProps<T, numberFieldProps<T>>,
) => {
const [local, rest] = splitProps(props as numberFieldProps, ["class"]);
return (
<NumberFieldPrimitive class={cn("grid gap-1.5", local.class)} {...rest} />
);
};
export const NumberFieldGroup = (props: ComponentProps<"div">) => {
const [local, rest] = splitProps(props, ["class"]);
return (
<div
class={cn(
"relative rounded-md transition-shadow focus-within:outline-none focus-within:ring-[1.5px] focus-within:ring-ring",
local.class,
)}
{...rest}
/>
);
};
type numberFieldInputProps<T extends ValidComponent = "input"> =
NumberFieldInputProps<T> & {
class?: string;
};
export const NumberFieldInput = <T extends ValidComponent = "input">(
props: PolymorphicProps<T, VoidProps<numberFieldInputProps<T>>>,
) => {
const [local, rest] = splitProps(props as numberFieldInputProps, ["class"]);
return (
<NumberFieldPrimitive.Input
class={cn(
"flex h-9 w-full rounded-md border border-input bg-transparent px-10 py-1 text-center text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50",
local.class,
)}
{...rest}
/>
);
};
type numberFieldDecrementTriggerProps<T extends ValidComponent = "button"> =
VoidProps<
NumberFieldDecrementTriggerProps<T> & {
class?: string;
}
>;
export const NumberFieldDecrementTrigger = <
T extends ValidComponent = "button",
>(
props: PolymorphicProps<T, VoidProps<numberFieldDecrementTriggerProps<T>>>,
) => {
const [local, rest] = splitProps(props as numberFieldDecrementTriggerProps, [
"class",
]);
return (
<NumberFieldPrimitive.DecrementTrigger
class={cn(
"absolute left-0 top-1/2 -translate-y-1/2 p-3 disabled:cursor-not-allowed disabled:opacity-20",
local.class,
)}
{...rest}
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="size-4"
viewBox="0 0 24 24"
>
<path
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 12h14"
/>
<title>Decreasing number</title>
</svg>
</NumberFieldPrimitive.DecrementTrigger>
);
};
type numberFieldIncrementTriggerProps<T extends ValidComponent = "button"> =
VoidProps<
NumberFieldIncrementTriggerProps<T> & {
class?: string;
}
>;
export const NumberFieldIncrementTrigger = <
T extends ValidComponent = "button",
>(
props: PolymorphicProps<T, numberFieldIncrementTriggerProps<T>>,
) => {
const [local, rest] = splitProps(props as numberFieldIncrementTriggerProps, [
"class",
]);
return (
<NumberFieldPrimitive.IncrementTrigger
class={cn(
"absolute right-0 top-1/2 -translate-y-1/2 p-3 disabled:cursor-not-allowed disabled:opacity-20",
local.class,
)}
{...rest}
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="size-4"
viewBox="0 0 24 24"
>
<path
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 5v14m-7-7h14"
/>
<title>Increase number</title>
</svg>
</NumberFieldPrimitive.IncrementTrigger>
);
};

View File

@@ -0,0 +1,83 @@
import { cn } from "@/libs/cn";
import type { DynamicProps, RootProps } from "@corvu/otp-field";
import OTPFieldPrimitive from "@corvu/otp-field";
import type { ComponentProps, ValidComponent } from "solid-js";
import { Show, splitProps } from "solid-js";
export const OTPFieldInput = OTPFieldPrimitive.Input;
type OTPFieldProps<T extends ValidComponent = "div"> = RootProps<T> & {
class?: string;
};
export const OTPField = <T extends ValidComponent = "div">(
props: DynamicProps<T, OTPFieldProps<T>>,
) => {
const [local, rest] = splitProps(props, ["class"]);
return (
<OTPFieldPrimitive
class={cn(
"flex items-center gap-2 has-[:disabled]:opacity-50",
local.class,
)}
{...rest}
/>
);
};
export const OTPFieldGroup = (props: ComponentProps<"div">) => {
const [local, rest] = splitProps(props, ["class"]);
return <div class={cn("flex items-center", local.class)} {...rest} />;
};
export const OTPFieldSeparator = (props: ComponentProps<"div">) => {
return (
// biome-ignore lint/a11y/useAriaPropsForRole: []
<div role="separator" {...props}>
<svg
xmlns="http://www.w3.org/2000/svg"
class="size-4"
viewBox="0 0 15 15"
>
<title>Separator</title>
<path
fill="currentColor"
fill-rule="evenodd"
d="M5 7.5a.5.5 0 0 1 .5-.5h4a.5.5 0 0 1 0 1h-4a.5.5 0 0 1-.5-.5"
clip-rule="evenodd"
/>
</svg>
</div>
);
};
export const OTPFieldSlot = (
props: ComponentProps<"div"> & { index: number },
) => {
const [local, rest] = splitProps(props, ["class", "index"]);
const context = OTPFieldPrimitive.useContext();
const char = () => context.value()[local.index];
const hasFakeCaret = () =>
context.value().length === local.index && context.isInserting();
const isActive = () => context.activeSlots().includes(local.index);
return (
<div
class={cn(
"relative flex size-9 items-center justify-center border-y border-r border-input text-sm shadow-sm transition-shadow first:rounded-l-md first:border-l last:rounded-r-md",
isActive() && "z-10 ring-[1.5px] ring-ring",
local.class,
)}
{...rest}
>
{char()}
<Show when={hasFakeCaret()}>
<div class="pointer-events-none absolute inset-0 flex items-center justify-center">
<div class="h-4 w-px animate-caret-blink bg-foreground" />
</div>
</Show>
</div>
);
};

View File

@@ -0,0 +1,194 @@
import { cn } from "@/libs/cn";
import type {
PaginationEllipsisProps,
PaginationItemProps,
PaginationPreviousProps,
PaginationRootProps,
} from "@kobalte/core/pagination";
import { Pagination as PaginationPrimitive } from "@kobalte/core/pagination";
import type { PolymorphicProps } from "@kobalte/core/polymorphic";
import type { VariantProps } from "class-variance-authority";
import type { ValidComponent, VoidProps } from "solid-js";
import { mergeProps, splitProps } from "solid-js";
import { buttonVariants } from "./button";
export const PaginationItems = PaginationPrimitive.Items;
type paginationProps<T extends ValidComponent = "nav"> =
PaginationRootProps<T> & {
class?: string;
};
export const Pagination = <T extends ValidComponent = "nav">(
props: PolymorphicProps<T, paginationProps<T>>,
) => {
const [local, rest] = splitProps(props as paginationProps, ["class"]);
return (
<PaginationPrimitive
class={cn(
"mx-auto flex w-full justify-center [&>ul]:flex [&>ul]:flex-row [&>ul]:items-center [&>ul]:gap-1",
local.class,
)}
{...rest}
/>
);
};
type paginationItemProps<T extends ValidComponent = "button"> =
PaginationItemProps<T> &
Pick<VariantProps<typeof buttonVariants>, "size"> & {
class?: string;
};
export const PaginationItem = <T extends ValidComponent = "button">(
props: PolymorphicProps<T, paginationItemProps<T>>,
) => {
// @ts-expect-error - required `page`
const merge = mergeProps<paginationItemProps[]>({ size: "icon" }, props);
const [local, rest] = splitProps(merge as paginationItemProps, [
"class",
"size",
]);
return (
<PaginationPrimitive.Item
class={cn(
buttonVariants({
variant: "ghost",
size: local.size,
}),
"aria-[current=page]:border aria-[current=page]:border-input aria-[current=page]:bg-background aria-[current=page]:shadow-sm aria-[current=page]:hover:bg-accent aria-[current=page]:hover:text-accent-foreground",
local.class,
)}
{...rest}
/>
);
};
type paginationEllipsisProps<T extends ValidComponent = "div"> = VoidProps<
PaginationEllipsisProps<T> & {
class?: string;
}
>;
export const PaginationEllipsis = <T extends ValidComponent = "div">(
props: PolymorphicProps<T, paginationEllipsisProps<T>>,
) => {
const [local, rest] = splitProps(props as paginationEllipsisProps, ["class"]);
return (
<PaginationPrimitive.Ellipsis
class={cn("flex h-9 w-9 items-center justify-center", local.class)}
{...rest}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
class="h-4 w-4"
>
<path
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 12a1 1 0 1 0 2 0a1 1 0 1 0-2 0m7 0a1 1 0 1 0 2 0a1 1 0 1 0-2 0m7 0a1 1 0 1 0 2 0a1 1 0 1 0-2 0"
/>
<title>More pages</title>
</svg>
</PaginationPrimitive.Ellipsis>
);
};
type paginationPreviousProps<T extends ValidComponent = "button"> =
PaginationPreviousProps<T> &
Pick<VariantProps<typeof buttonVariants>, "size"> & {
class?: string;
};
export const PaginationPrevious = <T extends ValidComponent = "button">(
props: PolymorphicProps<T, paginationPreviousProps<T>>,
) => {
const merge = mergeProps<paginationPreviousProps<T>[]>(
{ size: "icon" },
props,
);
const [local, rest] = splitProps(merge as paginationPreviousProps, [
"class",
"size",
]);
return (
<PaginationPrimitive.Previous
class={cn(
buttonVariants({
variant: "ghost",
size: local.size,
}),
"aria-[current=page]:border aria-[current=page]:border-input aria-[current=page]:bg-background aria-[current=page]:shadow-sm aria-[current=page]:hover:bg-accent aria-[current=page]:hover:text-accent-foreground",
local.class,
)}
{...rest}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
class="h-4 w-4"
>
<path
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="m15 6l-6 6l6 6"
/>
<title>Previous page</title>
</svg>
</PaginationPrimitive.Previous>
);
};
type paginationNextProps<T extends ValidComponent = "button"> =
paginationPreviousProps<T>;
export const PaginationNext = <T extends ValidComponent = "button">(
props: PolymorphicProps<T, paginationNextProps<T>>,
) => {
const merge = mergeProps<paginationNextProps<T>[]>({ size: "icon" }, props);
const [local, rest] = splitProps(merge as paginationNextProps, [
"class",
"size",
]);
return (
<PaginationPrimitive.Next
class={cn(
buttonVariants({
variant: "ghost",
size: local.size,
}),
"aria-[current=page]:border aria-[current=page]:border-input aria-[current=page]:bg-background aria-[current=page]:shadow-sm aria-[current=page]:hover:bg-accent aria-[current=page]:hover:text-accent-foreground",
local.class,
)}
{...rest}
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
viewBox="0 0 24 24"
>
<path
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="m9 6l6 6l-6 6"
/>
<title>Next page</title>
</svg>
</PaginationPrimitive.Next>
);
};

View File

@@ -0,0 +1,65 @@
import { cn } from "@/libs/cn";
import type { PolymorphicProps } from "@kobalte/core/polymorphic";
import type {
PopoverContentProps,
PopoverRootProps,
} from "@kobalte/core/popover";
import { Popover as PopoverPrimitive } from "@kobalte/core/popover";
import type { ParentProps, ValidComponent } from "solid-js";
import { mergeProps, splitProps } from "solid-js";
export const PopoverTrigger = PopoverPrimitive.Trigger;
export const PopoverTitle = PopoverPrimitive.Title;
export const PopoverDescription = PopoverPrimitive.Description;
export const Popover = (props: PopoverRootProps) => {
const merge = mergeProps<PopoverRootProps[]>({ gutter: 4 }, props);
return <PopoverPrimitive {...merge} />;
};
type popoverContentProps<T extends ValidComponent = "div"> = ParentProps<
PopoverContentProps<T> & {
class?: string;
}
>;
export const PopoverContent = <T extends ValidComponent = "div">(
props: PolymorphicProps<T, popoverContentProps<T>>,
) => {
const [local, rest] = splitProps(props as popoverContentProps, [
"class",
"children",
]);
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
class={cn(
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[expanded]:animate-in data-[closed]:animate-out data-[closed]:fade-out-0 data-[expanded]:fade-in-0 data-[closed]:zoom-out-95 data-[expanded]:zoom-in-95",
local.class,
)}
{...rest}
>
{local.children}
<PopoverPrimitive.CloseButton class="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-[opacity,box-shadow] hover:opacity-100 focus:outline-none focus:ring-[1.5px] focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
class="h-4 w-4"
>
<path
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M18 6L6 18M6 6l12 12"
/>
<title>Close</title>
</svg>
</PopoverPrimitive.CloseButton>
</PopoverPrimitive.Content>
</PopoverPrimitive.Portal>
);
};

View File

@@ -0,0 +1,36 @@
import { cn } from "@/libs/cn";
import type { PolymorphicProps } from "@kobalte/core/polymorphic";
import type { ProgressRootProps } from "@kobalte/core/progress";
import { Progress as ProgressPrimitive } from "@kobalte/core/progress";
import type { ParentProps, ValidComponent } from "solid-js";
import { splitProps } from "solid-js";
export const ProgressLabel = ProgressPrimitive.Label;
export const ProgressValueLabel = ProgressPrimitive.ValueLabel;
type progressProps<T extends ValidComponent = "div"> = ParentProps<
ProgressRootProps<T> & {
class?: string;
}
>;
export const Progress = <T extends ValidComponent = "div">(
props: PolymorphicProps<T, progressProps<T>>,
) => {
const [local, rest] = splitProps(props as progressProps, [
"class",
"children",
]);
return (
<ProgressPrimitive
class={cn("flex w-full flex-col gap-2", local.class)}
{...rest}
>
{local.children}
<ProgressPrimitive.Track class="h-2 overflow-hidden rounded-full bg-primary/20">
<ProgressPrimitive.Fill class="h-full w-[--kb-progress-fill-width] bg-primary transition-all duration-500 ease-linear data-[progress=complete]:bg-primary" />
</ProgressPrimitive.Track>
</ProgressPrimitive>
);
};

View File

@@ -0,0 +1,39 @@
import { cn } from "@/libs/cn";
import type { PolymorphicProps } from "@kobalte/core/polymorphic";
import type { RadioGroupItemControlProps } from "@kobalte/core/radio-group";
import { RadioGroup as RadioGroupPrimitive } from "@kobalte/core/radio-group";
import type { ValidComponent, VoidProps } from "solid-js";
import { splitProps } from "solid-js";
export const RadioGroupDescription = RadioGroupPrimitive.Description;
export const RadioGroupErrorMessage = RadioGroupPrimitive.ErrorMessage;
export const RadioGroupItemDescription = RadioGroupPrimitive.ItemDescription;
export const RadioGroupItemInput = RadioGroupPrimitive.ItemInput;
export const RadioGroupItemLabel = RadioGroupPrimitive.ItemLabel;
export const RadioGroupLabel = RadioGroupPrimitive.Label;
export const RadioGroup = RadioGroupPrimitive;
export const RadioGroupItem = RadioGroupPrimitive.Item;
type radioGroupItemControlProps<T extends ValidComponent = "div"> = VoidProps<
RadioGroupItemControlProps<T> & { class?: string }
>;
export const RadioGroupItemControl = <T extends ValidComponent = "div">(
props: PolymorphicProps<T, radioGroupItemControlProps<T>>,
) => {
const [local, rest] = splitProps(props as radioGroupItemControlProps, [
"class",
]);
return (
<RadioGroupPrimitive.ItemControl
class={cn(
"flex aspect-square h-4 w-4 items-center justify-center rounded-full border border-primary text-primary shadow transition-shadow focus:outline-none focus-visible:ring-[1.5px] focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[checked]:bg-foreground",
local.class,
)}
{...rest}
>
<RadioGroupPrimitive.ItemIndicator class="h-2 w-2 rounded-full data-[checked]:bg-background" />
</RadioGroupPrimitive.ItemControl>
);
};

View File

@@ -0,0 +1,63 @@
import { cn } from "@/libs/cn";
import type { DynamicProps, HandleProps, RootProps } from "@corvu/resizable";
import ResizablePrimitive from "@corvu/resizable";
import type { ValidComponent, VoidProps } from "solid-js";
import { Show, splitProps } from "solid-js";
export const ResizablePanel = ResizablePrimitive.Panel;
type resizableProps<T extends ValidComponent = "div"> = RootProps<T> & {
class?: string;
};
export const Resizable = <T extends ValidComponent = "div">(
props: DynamicProps<T, resizableProps<T>>,
) => {
const [local, rest] = splitProps(props as resizableProps, ["class"]);
return <ResizablePrimitive class={cn("size-full", local.class)} {...rest} />;
};
type resizableHandleProps<T extends ValidComponent = "button"> = VoidProps<
HandleProps<T> & {
class?: string;
withHandle?: boolean;
}
>;
export const ResizableHandle = <T extends ValidComponent = "button">(
props: DynamicProps<T, resizableHandleProps<T>>,
) => {
const [local, rest] = splitProps(props as resizableHandleProps, [
"class",
"withHandle",
]);
return (
<ResizablePrimitive.Handle
class={cn(
"flex w-px items-center justify-center bg-border transition-shadow focus-visible:outline-none focus-visible:ring-[1.5px] focus-visible:ring-ring focus-visible:ring-offset-1 data-[orientation=vertical]:h-px data-[orientation=vertical]:w-full",
local.class,
)}
{...rest}
>
<Show when={local.withHandle}>
<div class="z-10 flex h-4 w-3 items-center justify-center rounded-sm border bg-border">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-2.5 w-2.5"
viewBox="0 0 15 15"
>
<path
fill="currentColor"
fill-rule="evenodd"
d="M5.5 4.625a1.125 1.125 0 1 0 0-2.25a1.125 1.125 0 0 0 0 2.25m4 0a1.125 1.125 0 1 0 0-2.25a1.125 1.125 0 0 0 0 2.25M10.625 7.5a1.125 1.125 0 1 1-2.25 0a1.125 1.125 0 0 1 2.25 0M5.5 8.625a1.125 1.125 0 1 0 0-2.25a1.125 1.125 0 0 0 0 2.25m5.125 2.875a1.125 1.125 0 1 1-2.25 0a1.125 1.125 0 0 1 2.25 0M5.5 12.625a1.125 1.125 0 1 0 0-2.25a1.125 1.125 0 0 0 0 2.25"
clip-rule="evenodd"
/>
<title>Resizable handle</title>
</svg>
</div>
</Show>
</ResizablePrimitive.Handle>
);
};

View File

@@ -0,0 +1,127 @@
import { cn } from "@/libs/cn";
import type { PolymorphicProps } from "@kobalte/core/polymorphic";
import type {
SelectContentProps,
SelectItemProps,
SelectTriggerProps,
} from "@kobalte/core/select";
import { Select as SelectPrimitive } from "@kobalte/core/select";
import type { ParentProps, ValidComponent } from "solid-js";
import { splitProps } from "solid-js";
export const Select = SelectPrimitive;
export const SelectValue = SelectPrimitive.Value;
export const SelectDescription = SelectPrimitive.Description;
export const SelectErrorMessage = SelectPrimitive.ErrorMessage;
export const SelectItemDescription = SelectPrimitive.ItemDescription;
export const SelectHiddenSelect = SelectPrimitive.HiddenSelect;
export const SelectSection = SelectPrimitive.Section;
type selectTriggerProps<T extends ValidComponent = "button"> = ParentProps<
SelectTriggerProps<T> & { class?: string }
>;
export const SelectTrigger = <T extends ValidComponent = "button">(
props: PolymorphicProps<T, selectTriggerProps<T>>,
) => {
const [local, rest] = splitProps(props as selectTriggerProps, [
"class",
"children",
]);
return (
<SelectPrimitive.Trigger
class={cn(
"flex h-9 w-full items-center justify-between rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background transition-shadow placeholder:text-muted-foreground focus:outline-none focus-visible:ring-[1.5px] focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
local.class,
)}
{...rest}
>
{local.children}
<SelectPrimitive.Icon
as="svg"
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
viewBox="0 0 24 24"
class="flex size-4 items-center justify-center opacity-50"
>
<path
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="m8 9l4-4l4 4m0 6l-4 4l-4-4"
/>
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
);
};
type selectContentProps<T extends ValidComponent = "div"> =
SelectContentProps<T> & {
class?: string;
};
export const SelectContent = <T extends ValidComponent = "div">(
props: PolymorphicProps<T, selectContentProps<T>>,
) => {
const [local, rest] = splitProps(props as selectContentProps, ["class"]);
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
class={cn(
"relative z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[expanded]:animate-in data-[closed]:animate-out data-[closed]:fade-out-0 data-[expanded]:fade-in-0 data-[closed]:zoom-out-95 data-[expanded]:zoom-in-95",
local.class,
)}
{...rest}
>
<SelectPrimitive.Listbox class="p-1 focus-visible:outline-none" />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
);
};
type selectItemProps<T extends ValidComponent = "li"> = ParentProps<
SelectItemProps<T> & { class?: string }
>;
export const SelectItem = <T extends ValidComponent = "li">(
props: PolymorphicProps<T, selectItemProps<T>>,
) => {
const [local, rest] = splitProps(props as selectItemProps, [
"class",
"children",
]);
return (
<SelectPrimitive.Item
class={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
local.class,
)}
{...rest}
>
<SelectPrimitive.ItemIndicator class="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
viewBox="0 0 24 24"
>
<path
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="m5 12l5 5L20 7"
/>
<title>Checked</title>
</svg>
</SelectPrimitive.ItemIndicator>
<SelectPrimitive.ItemLabel>{local.children}</SelectPrimitive.ItemLabel>
</SelectPrimitive.Item>
);
};

View File

@@ -0,0 +1,26 @@
import { cn } from "@/libs/cn";
import type { PolymorphicProps } from "@kobalte/core/polymorphic";
import type { SeparatorRootProps } from "@kobalte/core/separator";
import { Separator as SeparatorPrimitive } from "@kobalte/core/separator";
import type { ValidComponent } from "solid-js";
import { splitProps } from "solid-js";
type separatorProps<T extends ValidComponent = "hr"> = SeparatorRootProps<T> & {
class?: string;
};
export const Separator = <T extends ValidComponent = "hr">(
props: PolymorphicProps<T, separatorProps<T>>,
) => {
const [local, rest] = splitProps(props as separatorProps, ["class"]);
return (
<SeparatorPrimitive
class={cn(
"shrink-0 bg-border data-[orientation=horizontal]:h-[1px] data-[orientation=vertical]:h-full data-[orientation=horizontal]:w-full data-[orientation=vertical]:w-[1px]",
local.class,
)}
{...rest}
/>
);
};

View File

@@ -0,0 +1,148 @@
import { cn } from "@/libs/cn";
import type {
DialogContentProps,
DialogDescriptionProps,
DialogTitleProps,
} from "@kobalte/core/dialog";
import { Dialog as DialogPrimitive } from "@kobalte/core/dialog";
import type { PolymorphicProps } from "@kobalte/core/polymorphic";
import type { VariantProps } from "class-variance-authority";
import { cva } from "class-variance-authority";
import type { ComponentProps, ParentProps, ValidComponent } from "solid-js";
import { mergeProps, splitProps } from "solid-js";
export const Sheet = DialogPrimitive;
export const SheetTrigger = DialogPrimitive.Trigger;
export const sheetVariants = cva(
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[expanded]:animate-in data-[closed]:animate-out data-[expanded]:duration-200 data-[closed]:duration-200",
{
variants: {
side: {
top: "inset-x-0 top-0 border-b data-[closed]:slide-out-to-top data-[expanded]:slide-in-from-top",
bottom:
"inset-x-0 bottom-0 border-t data-[closed]:slide-out-to-bottom data-[expanded]:slide-in-from-bottom",
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[closed]:slide-out-to-left data-[expanded]:slide-in-from-left sm:max-w-sm",
right:
"inset-y-0 right-0 h-full w-3/4 border-l data-[closed]:slide-out-to-right data-[expanded]:slide-in-from-right sm:max-w-sm",
},
},
defaultVariants: {
side: "right",
},
},
);
type sheetContentProps<T extends ValidComponent = "div"> = ParentProps<
DialogContentProps<T> &
VariantProps<typeof sheetVariants> & {
class?: string;
}
>;
export const SheetContent = <T extends ValidComponent = "div">(
props: PolymorphicProps<T, sheetContentProps<T>>,
) => {
const merge = mergeProps<sheetContentProps<T>[]>({ side: "right" }, props);
const [local, rest] = splitProps(merge as sheetContentProps, [
"class",
"children",
"side",
]);
return (
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay
class={cn(
"fixed inset-0 z-50 bg-background/80 data-[expanded]:animate-in data-[closed]:animate-out data-[closed]:fade-out-0 data-[expanded]:fade-in-0",
)}
/>
<DialogPrimitive.Content
class={sheetVariants({ side: local.side, class: local.class })}
{...rest}
>
{local.children}
<DialogPrimitive.CloseButton class="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-[opacity,box-shadow] hover:opacity-100 focus:outline-none focus:ring-[1.5px] focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
class="h-4 w-4"
>
<path
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M18 6L6 18M6 6l12 12"
/>
<title>Close</title>
</svg>
</DialogPrimitive.CloseButton>
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
);
};
type sheetTitleProps<T extends ValidComponent = "h2"> = DialogTitleProps<T> & {
class?: string;
};
export const SheetTitle = <T extends ValidComponent = "h2">(
props: PolymorphicProps<T, sheetTitleProps<T>>,
) => {
const [local, rest] = splitProps(props as sheetTitleProps, ["class"]);
return (
<DialogPrimitive.Title
class={cn("text-lg font-semibold text-foreground", local.class)}
{...rest}
/>
);
};
type sheetDescriptionProps<T extends ValidComponent = "p"> =
DialogDescriptionProps<T> & {
class?: string;
};
export const SheetDescription = <T extends ValidComponent = "p">(
props: PolymorphicProps<T, sheetDescriptionProps<T>>,
) => {
const [local, rest] = splitProps(props as sheetDescriptionProps, ["class"]);
return (
<DialogPrimitive.Description
class={cn("text-sm text-muted-foreground", local.class)}
{...rest}
/>
);
};
export const SheetHeader = (props: ComponentProps<"div">) => {
const [local, rest] = splitProps(props, ["class"]);
return (
<div
class={cn(
"flex flex-col space-y-2 text-center sm:text-left",
local.class,
)}
{...rest}
/>
);
};
export const SheetFooter = (props: ComponentProps<"div">) => {
const [local, rest] = splitProps(props, ["class"]);
return (
<div
class={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
local.class,
)}
{...rest}
/>
);
};

View File

@@ -0,0 +1,13 @@
import { cn } from "@/libs/cn";
import { type ComponentProps, splitProps } from "solid-js";
export const Skeleton = (props: ComponentProps<"div">) => {
const [local, rest] = splitProps(props, ["class"]);
return (
<div
class={cn("animate-pulse rounded-md bg-primary/10", local.class)}
{...rest}
/>
);
};

View File

@@ -0,0 +1,21 @@
import { Toaster as Sonner } from "solid-sonner";
export const Toaster = (props: Parameters<typeof Sonner>[0]) => {
return (
<Sonner
class="toaster group"
toastOptions={{
classes: {
toast:
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
description: "group-[.toast]:text-muted-foreground",
actionButton:
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
cancelButton:
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
},
}}
{...props}
/>
);
};

View File

@@ -0,0 +1,62 @@
import { cn } from "@/libs/cn";
import type { PolymorphicProps } from "@kobalte/core/polymorphic";
import type {
SwitchControlProps,
SwitchThumbProps,
} from "@kobalte/core/switch";
import { Switch as SwitchPrimitive } from "@kobalte/core/switch";
import type { ParentProps, ValidComponent, VoidProps } from "solid-js";
import { splitProps } from "solid-js";
export const SwitchLabel = SwitchPrimitive.Label;
export const Switch = SwitchPrimitive;
export const SwitchErrorMessage = SwitchPrimitive.ErrorMessage;
export const SwitchDescription = SwitchPrimitive.Description;
type switchControlProps<T extends ValidComponent = "input"> = ParentProps<
SwitchControlProps<T> & { class?: string }
>;
export const SwitchControl = <T extends ValidComponent = "input">(
props: PolymorphicProps<T, switchControlProps<T>>,
) => {
const [local, rest] = splitProps(props as switchControlProps, [
"class",
"children",
]);
return (
<>
<SwitchPrimitive.Input class="[&:focus-visible+div]:outline-none [&:focus-visible+div]:ring-[1.5px] [&:focus-visible+div]:ring-ring [&:focus-visible+div]:ring-offset-2 [&:focus-visible+div]:ring-offset-background" />
<SwitchPrimitive.Control
class={cn(
"inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent bg-input shadow-sm transition-[color,background-color,box-shadow] data-[disabled]:cursor-not-allowed data-[checked]:bg-primary data-[disabled]:opacity-50",
local.class,
)}
{...rest}
>
{local.children}
</SwitchPrimitive.Control>
</>
);
};
type switchThumbProps<T extends ValidComponent = "div"> = VoidProps<
SwitchThumbProps<T> & { class?: string }
>;
export const SwitchThumb = <T extends ValidComponent = "div">(
props: PolymorphicProps<T, switchThumbProps<T>>,
) => {
const [local, rest] = splitProps(props as switchThumbProps, ["class"]);
return (
<SwitchPrimitive.Thumb
class={cn(
"pointer-events-none block h-4 w-4 translate-x-0 rounded-full bg-background shadow-lg ring-0 transition-transform data-[checked]:translate-x-4",
local.class,
)}
{...rest}
/>
);
};

View File

@@ -0,0 +1,93 @@
import { cn } from "@/libs/cn";
import { type ComponentProps, splitProps } from "solid-js";
export const Table = (props: ComponentProps<"table">) => {
const [local, rest] = splitProps(props, ["class"]);
return (
<div class="w-full overflow-auto">
<table
class={cn("w-full caption-bottom text-sm", local.class)}
{...rest}
/>
</div>
);
};
export const TableHeader = (props: ComponentProps<"thead">) => {
const [local, rest] = splitProps(props, ["class"]);
return <thead class={cn("[&_tr]:border-b", local.class)} {...rest} />;
};
export const TableBody = (props: ComponentProps<"tbody">) => {
const [local, rest] = splitProps(props, ["class"]);
return (
<tbody class={cn("[&_tr:last-child]:border-0", local.class)} {...rest} />
);
};
export const TableFooter = (props: ComponentProps<"tfoot">) => {
const [local, rest] = splitProps(props, ["class"]);
return (
<tbody
class={cn("bg-primary font-medium text-primary-foreground", local.class)}
{...rest}
/>
);
};
export const TableRow = (props: ComponentProps<"tr">) => {
const [local, rest] = splitProps(props, ["class"]);
return (
<tr
class={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
local.class,
)}
{...rest}
/>
);
};
export const TableHead = (props: ComponentProps<"th">) => {
const [local, rest] = splitProps(props, ["class"]);
return (
<th
class={cn(
"h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
local.class,
)}
{...rest}
/>
);
};
export const TableCell = (props: ComponentProps<"td">) => {
const [local, rest] = splitProps(props, ["class"]);
return (
<td
class={cn(
"p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
local.class,
)}
{...rest}
/>
);
};
export const TableCaption = (props: ComponentProps<"caption">) => {
const [local, rest] = splitProps(props, ["class"]);
return (
<caption
class={cn("mt-4 text-sm text-muted-foreground", local.class)}
{...rest}
/>
);
};

View File

@@ -0,0 +1,133 @@
import { cn } from "@/libs/cn";
import type { PolymorphicProps } from "@kobalte/core/polymorphic";
import type {
TabsContentProps,
TabsIndicatorProps,
TabsListProps,
TabsRootProps,
TabsTriggerProps,
} from "@kobalte/core/tabs";
import { Tabs as TabsPrimitive } from "@kobalte/core/tabs";
import type { VariantProps } from "class-variance-authority";
import { cva } from "class-variance-authority";
import type { ValidComponent, VoidProps } from "solid-js";
import { splitProps } from "solid-js";
type tabsProps<T extends ValidComponent = "div"> = TabsRootProps<T> & {
class?: string;
};
export const Tabs = <T extends ValidComponent = "div">(
props: PolymorphicProps<T, tabsProps<T>>,
) => {
const [local, rest] = splitProps(props as tabsProps, ["class"]);
return (
<TabsPrimitive
class={cn("w-full data-[orientation=vertical]:flex", local.class)}
{...rest}
/>
);
};
type tabsListProps<T extends ValidComponent = "div"> = TabsListProps<T> & {
class?: string;
};
export const TabsList = <T extends ValidComponent = "div">(
props: PolymorphicProps<T, tabsListProps<T>>,
) => {
const [local, rest] = splitProps(props as tabsListProps, ["class"]);
return (
<TabsPrimitive.List
class={cn(
"relative flex w-full rounded-lg bg-muted p-1 text-muted-foreground data-[orientation=vertical]:flex-col data-[orientation=horizontal]:items-center data-[orientation=vertical]:items-stretch",
local.class,
)}
{...rest}
/>
);
};
type tabsContentProps<T extends ValidComponent = "div"> =
TabsContentProps<T> & {
class?: string;
};
export const TabsContent = <T extends ValidComponent = "div">(
props: PolymorphicProps<T, tabsContentProps<T>>,
) => {
const [local, rest] = splitProps(props as tabsContentProps, ["class"]);
return (
<TabsPrimitive.Content
class={cn(
"transition-shadow duration-200 focus-visible:outline-none focus-visible:ring-[1.5px] focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background data-[orientation=horizontal]:mt-2 data-[orientation=vertical]:ml-2",
local.class,
)}
{...rest}
/>
);
};
type tabsTriggerProps<T extends ValidComponent = "button"> =
TabsTriggerProps<T> & {
class?: string;
};
export const TabsTrigger = <T extends ValidComponent = "button">(
props: PolymorphicProps<T, tabsTriggerProps<T>>,
) => {
const [local, rest] = splitProps(props as tabsTriggerProps, ["class"]);
return (
<TabsPrimitive.Trigger
class={cn(
"peer relative z-10 inline-flex h-7 w-full items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium outline-none transition-colors disabled:pointer-events-none disabled:opacity-50 data-[selected]:text-foreground",
local.class,
)}
{...rest}
/>
);
};
const tabsIndicatorVariants = cva(
"absolute transition-all duration-200 outline-none",
{
variants: {
variant: {
block:
"data-[orientation=horizontal]:bottom-1 data-[orientation=horizontal]:left-0 data-[orientation=vertical]:right-1 data-[orientation=vertical]:top-0 data-[orientation=horizontal]:h-[calc(100%-0.5rem)] data-[orientation=vertical]:w-[calc(100%-0.5rem)] bg-background shadow rounded-md peer-focus-visible:ring-[1.5px] peer-focus-visible:ring-ring peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-background peer-focus-visible:outline-none",
underline:
"data-[orientation=horizontal]:-bottom-[1px] data-[orientation=horizontal]:left-0 data-[orientation=vertical]:-right-[1px] data-[orientation=vertical]:top-0 data-[orientation=horizontal]:h-[2px] data-[orientation=vertical]:w-[2px] bg-primary",
},
},
defaultVariants: {
variant: "block",
},
},
);
type tabsIndicatorProps<T extends ValidComponent = "div"> = VoidProps<
TabsIndicatorProps<T> &
VariantProps<typeof tabsIndicatorVariants> & {
class?: string;
}
>;
export const TabsIndicator = <T extends ValidComponent = "div">(
props: PolymorphicProps<T, tabsIndicatorProps<T>>,
) => {
const [local, rest] = splitProps(props as tabsIndicatorProps, [
"class",
"variant",
]);
return (
<TabsPrimitive.Indicator
class={cn(tabsIndicatorVariants({ variant: local.variant }), local.class)}
{...rest}
/>
);
};

View File

@@ -0,0 +1,28 @@
import { cn } from "@/libs/cn";
import type { PolymorphicProps } from "@kobalte/core/polymorphic";
import type { TextFieldTextAreaProps } from "@kobalte/core/text-field";
import { TextArea as TextFieldPrimitive } from "@kobalte/core/text-field";
import type { ValidComponent, VoidProps } from "solid-js";
import { splitProps } from "solid-js";
type textAreaProps<T extends ValidComponent = "textarea"> = VoidProps<
TextFieldTextAreaProps<T> & {
class?: string;
}
>;
export const TextArea = <T extends ValidComponent = "textarea">(
props: PolymorphicProps<T, textAreaProps<T>>,
) => {
const [local, rest] = splitProps(props as textAreaProps, ["class"]);
return (
<TextFieldPrimitive
class={cn(
"flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm transition-shadow placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-[1.5px] focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
local.class,
)}
{...rest}
/>
);
};

View File

@@ -0,0 +1,129 @@
import { cn } from "@/libs/cn";
import type { PolymorphicProps } from "@kobalte/core/polymorphic";
import type {
TextFieldDescriptionProps,
TextFieldErrorMessageProps,
TextFieldInputProps,
TextFieldLabelProps,
TextFieldRootProps,
} from "@kobalte/core/text-field";
import { TextField as TextFieldPrimitive } from "@kobalte/core/text-field";
import { cva } from "class-variance-authority";
import type { ValidComponent, VoidProps } from "solid-js";
import { splitProps } from "solid-js";
type textFieldProps<T extends ValidComponent = "div"> =
TextFieldRootProps<T> & {
class?: string;
};
export const TextFieldRoot = <T extends ValidComponent = "div">(
props: PolymorphicProps<T, textFieldProps<T>>
) => {
const [local, rest] = splitProps(props as textFieldProps, ["class"]);
return <TextFieldPrimitive class={cn("space-y-1", local.class)} {...rest} />;
};
export const textfieldLabel = cva(
"text-sm data-[disabled]:cursor-not-allowed data-[disabled]:opacity-70 font-medium",
{
variants: {
label: {
true: "data-[invalid]:text-destructive",
},
error: {
true: "text-destructive text-xs",
},
description: {
true: "font-normal text-muted-foreground",
},
},
defaultVariants: {
label: true,
},
}
);
type textFieldLabelProps<T extends ValidComponent = "label"> =
TextFieldLabelProps<T> & {
class?: string;
};
export const TextFieldLabel = <T extends ValidComponent = "label">(
props: PolymorphicProps<T, textFieldLabelProps<T>>
) => {
const [local, rest] = splitProps(props as textFieldLabelProps, ["class"]);
return (
<TextFieldPrimitive.Label
class={cn(textfieldLabel(), local.class)}
{...rest}
/>
);
};
type textFieldErrorMessageProps<T extends ValidComponent = "div"> =
TextFieldErrorMessageProps<T> & {
class?: string;
};
export const TextFieldErrorMessage = <T extends ValidComponent = "div">(
props: PolymorphicProps<T, textFieldErrorMessageProps<T>>
) => {
const [local, rest] = splitProps(props as textFieldErrorMessageProps, [
"class",
]);
return (
<TextFieldPrimitive.ErrorMessage
class={cn(textfieldLabel({ error: true }), local.class)}
{...rest}
/>
);
};
type textFieldDescriptionProps<T extends ValidComponent = "div"> =
TextFieldDescriptionProps<T> & {
class?: string;
};
export const TextFieldDescription = <T extends ValidComponent = "div">(
props: PolymorphicProps<T, textFieldDescriptionProps<T>>
) => {
const [local, rest] = splitProps(props as textFieldDescriptionProps, [
"class",
]);
return (
<TextFieldPrimitive.Description
class={cn(
textfieldLabel({ description: true, label: false }),
local.class
)}
{...rest}
/>
);
};
type textFieldInputProps<T extends ValidComponent = "input"> = VoidProps<
TextFieldInputProps<T> & {
class?: string;
}
>;
export const TextField = <T extends ValidComponent = "input">(
props: PolymorphicProps<T, textFieldInputProps<T>>
) => {
const [local, rest] = splitProps(props as textFieldInputProps, ["class"]);
return (
<TextFieldPrimitive.Input
class={cn(
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-shadow file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-[1.5px] focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
local.class
)}
{...rest}
/>
);
};

View File

@@ -0,0 +1,168 @@
import { cn } from "@/libs/cn";
import type { PolymorphicProps } from "@kobalte/core/polymorphic";
import type {
ToastDescriptionProps,
ToastListProps,
ToastRegionProps,
ToastRootProps,
ToastTitleProps,
} from "@kobalte/core/toast";
import { Toast as ToastPrimitive } from "@kobalte/core/toast";
import type { VariantProps } from "class-variance-authority";
import { cva } from "class-variance-authority";
import type {
ComponentProps,
ValidComponent,
VoidComponent,
VoidProps,
} from "solid-js";
import { mergeProps, splitProps } from "solid-js";
import { Portal } from "solid-js/web";
export const toastVariants = cva(
"group pointer-events-auto relative flex flex-col gap-3 w-full items-center justify-between overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-y-0 data-[swipe=end]:translate-y-[var(--kb-toast-swipe-end-y)] data-[swipe=move]:translate-y-[--kb-toast-swipe-move-y] data-[swipe=move]:transition-none data-[opened]:animate-in data-[closed]:animate-out data-[swipe=end]:animate-out data-[closed]:fade-out-80 data-[closed]:slide-out-to-top-full data-[closed]:sm:slide-out-to-bottom-full data-[opened]:slide-in-from-top-full data-[opened]:sm:slide-in-from-bottom-full",
{
variants: {
variant: {
default: "border bg-background",
destructive:
"destructive group border-destructive bg-destructive text-destructive-foreground",
},
},
defaultVariants: {
variant: "default",
},
},
);
type toastProps<T extends ValidComponent = "li"> = ToastRootProps<T> &
VariantProps<typeof toastVariants> & {
class?: string;
};
export const Toast = <T extends ValidComponent = "li">(
props: PolymorphicProps<T, toastProps<T>>,
) => {
const [local, rest] = splitProps(props as toastProps, ["class", "variant"]);
return (
<ToastPrimitive
class={cn(toastVariants({ variant: local.variant }), local.class)}
{...rest}
/>
);
};
type toastTitleProps<T extends ValidComponent = "div"> = ToastTitleProps<T> & {
class?: string;
};
export const ToastTitle = <T extends ValidComponent = "div">(
props: PolymorphicProps<T, toastTitleProps<T>>,
) => {
const [local, rest] = splitProps(props as toastTitleProps, ["class"]);
return (
<ToastPrimitive.Title
class={cn("text-sm font-semibold [&+div]:text-xs", local.class)}
{...rest}
/>
);
};
type toastDescriptionProps<T extends ValidComponent = "div"> =
ToastDescriptionProps<T> & {
class?: string;
};
export const ToastDescription = <T extends ValidComponent = "div">(
props: PolymorphicProps<T, toastDescriptionProps<T>>,
) => {
const [local, rest] = splitProps(props as toastDescriptionProps, ["class"]);
return (
<ToastPrimitive.Description
class={cn("text-sm opacity-90", local.class)}
{...rest}
/>
);
};
type toastRegionProps<T extends ValidComponent = "div"> =
ToastRegionProps<T> & {
class?: string;
};
export const ToastRegion = <T extends ValidComponent = "div">(
props: PolymorphicProps<T, toastRegionProps<T>>,
) => {
const merge = mergeProps<toastRegionProps[]>(
{
swipeDirection: "down",
},
props,
);
return (
<Portal>
<ToastPrimitive.Region {...merge} />
</Portal>
);
};
type toastListProps<T extends ValidComponent = "ol"> = VoidProps<
ToastListProps<T> & {
class?: string;
}
>;
export const ToastList = <T extends ValidComponent = "ol">(
props: PolymorphicProps<T, toastListProps<T>>,
) => {
const [local, rest] = splitProps(props as toastListProps, ["class"]);
return (
<ToastPrimitive.List
class={cn(
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse gap-2 p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
local.class,
)}
{...rest}
/>
);
};
export const ToastContent = (props: ComponentProps<"div">) => {
const [local, rest] = splitProps(props, ["class", "children"]);
return (
<div class={cn("flex w-full flex-col", local.class)} {...rest}>
<div>{local.children}</div>
<ToastPrimitive.CloseButton class="absolute right-1 top-1 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
viewBox="0 0 24 24"
>
<path
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M18 6L6 18M6 6l12 12"
/>
<title>Close</title>
</svg>
</ToastPrimitive.CloseButton>
</div>
);
};
export const ToastProgress: VoidComponent = () => {
return (
<ToastPrimitive.ProgressTrack class="h-1 w-full overflow-hidden rounded-xl bg-primary/20 group-[.destructive]:bg-background/20">
<ToastPrimitive.ProgressFill class="h-full w-[--kb-toast-progress-fill-width] bg-primary transition-all duration-150 ease-linear group-[.destructive]:bg-destructive-foreground" />
</ToastPrimitive.ProgressTrack>
);
};

View File

@@ -0,0 +1,85 @@
import { cn } from "@/libs/cn";
import type { PolymorphicProps } from "@kobalte/core/polymorphic";
import type {
ToggleGroupItemProps,
ToggleGroupRootProps,
} from "@kobalte/core/toggle-group";
import { ToggleGroup as ToggleGroupPrimitive } from "@kobalte/core/toggle-group";
import type { VariantProps } from "class-variance-authority";
import type { Accessor, ParentProps, ValidComponent } from "solid-js";
import { createContext, createMemo, splitProps, useContext } from "solid-js";
import { toggleVariants } from "./toggle";
const ToggleGroupContext =
createContext<Accessor<VariantProps<typeof toggleVariants>>>();
const useToggleGroup = () => {
const context = useContext(ToggleGroupContext);
if (!context) {
throw new Error(
"`useToggleGroup`: must be used within a `ToggleGroup` component",
);
}
return context;
};
type toggleGroupProps<T extends ValidComponent = "div"> = ParentProps<
ToggleGroupRootProps<T> &
VariantProps<typeof toggleVariants> & {
class?: string;
}
>;
export const ToggleGroup = <T extends ValidComponent = "div">(
props: PolymorphicProps<T, toggleGroupProps<T>>,
) => {
const [local, rest] = splitProps(props as toggleGroupProps, [
"class",
"children",
"size",
"variant",
]);
const value = createMemo<VariantProps<typeof toggleVariants>>(() => ({
size: local.size,
variant: local.variant,
}));
return (
<ToggleGroupPrimitive
class={cn("flex items-center justify-center gap-1", local.class)}
{...rest}
>
<ToggleGroupContext.Provider value={value}>
{local.children}
</ToggleGroupContext.Provider>
</ToggleGroupPrimitive>
);
};
type toggleGroupItemProps<T extends ValidComponent = "button"> =
ToggleGroupItemProps<T> & {
class?: string;
};
export const ToggleGroupItem = <T extends ValidComponent = "button">(
props: PolymorphicProps<T, toggleGroupItemProps<T>>,
) => {
const [local, rest] = splitProps(props as toggleGroupItemProps, ["class"]);
const context = useToggleGroup();
return (
<ToggleGroupPrimitive.Item
class={cn(
toggleVariants({
variant: context().variant,
size: context().size,
}),
local.class,
)}
{...rest}
/>
);
};

View File

@@ -0,0 +1,56 @@
import { cn } from "@/libs/cn";
import type { PolymorphicProps } from "@kobalte/core/polymorphic";
import type { ToggleButtonRootProps } from "@kobalte/core/toggle-button";
import { ToggleButton as ToggleButtonPrimitive } from "@kobalte/core/toggle-button";
import type { VariantProps } from "class-variance-authority";
import { cva } from "class-variance-authority";
import type { ValidComponent } from "solid-js";
import { splitProps } from "solid-js";
export const toggleVariants = cva(
"inline-flex items-center justify-center rounded-md text-sm font-medium transition-[box-shadow,color,background-color] hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-[1.5px] focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 data-[pressed]:bg-accent data-[pressed]:text-accent-foreground",
{
variants: {
variant: {
default: "bg-transparent",
outline:
"border border-input bg-transparent shadow-sm hover:bg-accent hover:text-accent-foreground",
},
size: {
default: "h-9 px-3",
sm: "h-8 px-2",
lg: "h-10 px-3",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
type toggleButtonProps<T extends ValidComponent = "button"> =
ToggleButtonRootProps<T> &
VariantProps<typeof toggleVariants> & {
class?: string;
};
export const ToggleButton = <T extends ValidComponent = "button">(
props: PolymorphicProps<T, toggleButtonProps<T>>,
) => {
const [local, rest] = splitProps(props as toggleButtonProps, [
"class",
"variant",
"size",
]);
return (
<ToggleButtonPrimitive
class={cn(
toggleVariants({ variant: local.variant, size: local.size }),
local.class,
)}
{...rest}
/>
);
};

View File

@@ -0,0 +1,39 @@
import { cn } from "@/libs/cn";
import type { PolymorphicProps } from "@kobalte/core/polymorphic";
import type {
TooltipContentProps,
TooltipRootProps,
} from "@kobalte/core/tooltip";
import { Tooltip as TooltipPrimitive } from "@kobalte/core/tooltip";
import { type ValidComponent, mergeProps, splitProps } from "solid-js";
export const TooltipTrigger = TooltipPrimitive.Trigger;
export const Tooltip = (props: TooltipRootProps) => {
const merge = mergeProps<TooltipRootProps[]>({ gutter: 4 }, props);
return <TooltipPrimitive {...merge} />;
};
type tooltipContentProps<T extends ValidComponent = "div"> =
TooltipContentProps<T> & {
class?: string;
};
export const TooltipContent = <T extends ValidComponent = "div">(
props: PolymorphicProps<T, tooltipContentProps<T>>,
) => {
const [local, rest] = splitProps(props as tooltipContentProps, ["class"]);
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
class={cn(
"z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground data-[expanded]:animate-in data-[closed]:animate-out data-[closed]:fade-out-0 data-[expanded]:fade-in-0 data-[closed]:zoom-out-95 data-[expanded]:zoom-in-95",
local.class,
)}
{...rest}
/>
</TooltipPrimitive.Portal>
);
};

View File

@@ -0,0 +1,488 @@
import {
passkeyActions,
signOut,
twoFactorActions,
useListPasskeys,
userActions,
useSession,
} from "@/libs/auth-client";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "./ui/card";
import { UAParser } from "ua-parser-js";
import { Image, ImageFallback, ImageRoot } from "./ui/image";
import type { Session, User } from "better-auth/types";
import { createEffect, createSignal, Show } from "solid-js";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "./ui/dialog";
import { Button } from "./ui/button";
import { TextField, TextFieldLabel, TextFieldRoot } from "./ui/textfield";
import { convertImageToBase64 } from "@/libs/utils";
import { Loader } from "./loader";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "./ui/table";
import type { ActiveSession } from "@/libs/types";
export function UserCard({
activeSessions,
initialSession,
}: {
activeSessions: Session[];
initialSession: ActiveSession | null;
}) {
const [session, setSession] = createSignal(initialSession);
const res = useSession();
createEffect(() => {
setSession(res().data);
});
return (
<Card class="w-full">
<CardHeader>
<CardTitle>User</CardTitle>
</CardHeader>
<CardContent class="grid gap-8 grid-cols-1">
<div class="flex items-start justify-between">
<div class="flex items-center gap-4">
<ImageRoot>
<Image src={session()?.user.image} alt="picture" />
<ImageFallback>{session()?.user.name.charAt(0)}</ImageFallback>
</ImageRoot>
<div class="grid gap-1">
<p class="text-sm font-medium leading-none">
{session()?.user.name}
</p>
<p class="text-sm">{session()?.user.email}</p>
</div>
</div>
<EditUserDialog user={session()?.user} />
</div>
<div class="border-l-2 px-2 w-max gap-1 flex flex-col">
<p class="text-xs font-medium ">Active Sessions</p>
{activeSessions.map((activeSession) => {
return (
<div>
<div class="flex items-center gap-2 text-sm text-black font-medium dark:text-white">
{new UAParser(activeSession.userAgent).getDevice().type ===
"mobile" ? (
<svg
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
viewBox="0 0 24 24"
>
<path
fill="currentColor"
d="M15.5 1h-8A2.5 2.5 0 0 0 5 3.5v17A2.5 2.5 0 0 0 7.5 23h8a2.5 2.5 0 0 0 2.5-2.5v-17A2.5 2.5 0 0 0 15.5 1m-4 21c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5s1.5.67 1.5 1.5s-.67 1.5-1.5 1.5m4.5-4H7V4h9z"
></path>
</svg>
) : (
<svg
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
viewBox="0 0 24 24"
>
<path
fill="currentColor"
d="M0 20v-2h4v-1q-.825 0-1.412-.587T2 15V5q0-.825.588-1.412T4 3h16q.825 0 1.413.588T22 5v10q0 .825-.587 1.413T20 17v1h4v2zm4-5h16V5H4zm0 0V5z"
></path>
</svg>
)}
{new UAParser(activeSession.userAgent).getOS().name},{" "}
{new UAParser(activeSession.userAgent).getBrowser().name}
<button
class="text-red-500 opacity-80 cursor-pointer text-xs border-muted-foreground border-red-600 underline "
onClick={async () => {
const res = await userActions.revokeSession({
id: activeSession.id,
});
if (res.error) {
alert(res.error.message);
} else {
alert("Session terminated");
}
}}
>
{activeSession.id === session()?.session.id
? "Sign Out"
: "Terminate"}
</button>
</div>
</div>
);
})}
</div>
<div class="flex items-center justify-between">
<Button
variant="outline"
class="gap-2"
onClick={async () => {
await signOut();
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="1.2em"
height="1.2em"
viewBox="0 0 24 24"
>
<path
fill="currentColor"
d="M9 20.75H6a2.64 2.64 0 0 1-2.75-2.53V5.78A2.64 2.64 0 0 1 6 3.25h3a.75.75 0 0 1 0 1.5H6a1.16 1.16 0 0 0-1.25 1v12.47a1.16 1.16 0 0 0 1.25 1h3a.75.75 0 0 1 0 1.5Zm7-4a.74.74 0 0 1-.53-.22a.75.75 0 0 1 0-1.06L18.94 12l-3.47-3.47a.75.75 0 1 1 1.06-1.06l4 4a.75.75 0 0 1 0 1.06l-4 4a.74.74 0 0 1-.53.22"
></path>
<path
fill="currentColor"
d="M20 12.75H9a.75.75 0 0 1 0-1.5h11a.75.75 0 0 1 0 1.5"
></path>
</svg>
Sign Out
</Button>
<div>
<TwoFactorDialog enabled={session()?.user.twoFactorEnabled} />
</div>
</div>
<div class="border-y py-4 flex items-center flex-wrap justify-between gap-2">
<div class="flex flex-col gap-2">
<p class="text-sm">Passkeys</p>
<div class="flex gap-2 flex-wrap">
<AddPasskeyDialog />
<ListPasskeys />
</div>
</div>
</div>
</CardContent>
</Card>
);
}
function EditUserDialog(props: { user?: User }) {
const user = props.user;
const [isLoading, setIsLoading] = createSignal(false);
const [image, setImage] = createSignal<File>();
const [name, setName] = createSignal<string>();
const [isOpen, setIsOpen] = createSignal(false);
return (
<Dialog onOpenChange={setIsOpen} open={isOpen()}>
<DialogTrigger>
<Button variant="secondary">Edit User</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit User</DialogTitle>
<DialogDescription>Edit User Information</DialogDescription>
</DialogHeader>
<div class="grid gap-2">
<TextFieldRoot>
<TextFieldLabel for="full-name">Full Name</TextFieldLabel>
<TextField
placeholder={user?.name}
type="text"
value={name()}
onInput={(e) => {
if ("value" in e.target) setName(e.target.value as string);
}}
/>
</TextFieldRoot>
<TextFieldRoot>
<TextFieldLabel>Profile Image</TextFieldLabel>
<TextField
type="file"
onChange={(e: any) => {
const file = e.target.files?.[0];
if ("value" in e.target) setImage(file);
}}
/>
</TextFieldRoot>
</div>
<DialogFooter>
<Button
onClick={async () => {
setIsLoading(true);
await userActions.update({
image: image()
? await convertImageToBase64(image()!)
: undefined,
name: name(),
fetchOptions: {
onResponse(context) {
setIsLoading(false);
},
onError(context) {
alert(context.error.message);
},
onSuccess() {
alert("User Updated Successfully");
setIsOpen(false);
},
},
});
}}
>
<Show fallback={<p>Update</p>} when={isLoading()}>
<Loader />
</Show>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function AddPasskeyDialog() {
const [name, setName] = createSignal("");
const [isLoading, setIsLoading] = createSignal(false);
return (
<Dialog>
<DialogTrigger>
<Button variant="outline">Add Passkey</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Register New Passkey</DialogTitle>
<DialogDescription>
Add a new passkey to your account
</DialogDescription>
</DialogHeader>
<div class="grid gap-2">
<TextFieldRoot>
<TextFieldLabel for="passkey-name">
Passkey Name (optional)
</TextFieldLabel>
<TextField
type="text"
placeholder="My Passkey"
value={name()}
onInput={(e) => {
if ("value" in e.target) setName(e.target.value as string);
}}
/>
</TextFieldRoot>
</div>
<DialogFooter>
<Button
onClick={async () => {
const res = await passkeyActions.addPasskey({
name: name(),
fetchOptions: {
onSuccess() {
alert("Successfully added");
setName("");
},
},
});
if (res?.error) {
alert(res.error.message);
}
}}
>
<Show
when={isLoading()}
fallback={
<div class="flex items-center gap-2">
<svg
xmlns="http://www.w3.org/2000/svg"
width="1.2em"
height="1.2em"
viewBox="0 0 24 24"
>
<path
fill="currentColor"
d="M3.25 9.65q-.175-.125-.213-.312t.113-.388q1.55-2.125 3.888-3.3t4.987-1.175q2.65 0 5 1.138T20.95 8.9q.175.225.113.4t-.213.3q-.15.125-.35.113t-.35-.213q-1.375-1.95-3.537-2.987t-4.588-1.038q-2.425 0-4.55 1.038T3.95 9.5q-.15.225-.35.25t-.35-.1m11.6 12.325q-2.6-.65-4.25-2.588T8.95 14.65q0-1.25.9-2.1t2.175-.85q1.275 0 2.175.85t.9 2.1q0 .825.625 1.388t1.475.562q.85 0 1.45-.562t.6-1.388q0-2.9-2.125-4.875T12.05 7.8q-2.95 0-5.075 1.975t-2.125 4.85q0 .6.113 1.5t.537 2.1q.075.225-.012.4t-.288.25q-.2.075-.387-.012t-.263-.288q-.375-.975-.537-1.937T3.85 14.65q0-3.325 2.413-5.575t5.762-2.25q3.375 0 5.8 2.25t2.425 5.575q0 1.25-.887 2.087t-2.163.838q-1.275 0-2.187-.837T14.1 14.65q0-.825-.612-1.388t-1.463-.562q-.85 0-1.463.563T9.95 14.65q0 2.425 1.438 4.05t3.712 2.275q.225.075.3.25t.025.375q-.05.175-.2.3t-.375.075M6.5 4.425q-.2.125-.4.063t-.3-.263q-.1-.2-.05-.362T6 3.575q1.4-.75 2.925-1.15t3.1-.4q1.6 0 3.125.388t2.95 1.112q.225.125.263.3t-.038.35q-.075.175-.25.275t-.425-.025q-1.325-.675-2.738-1.037t-2.887-.363q-1.45 0-2.85.338T6.5 4.425m2.95 17.2q-1.475-1.55-2.262-3.162T6.4 14.65q0-2.275 1.65-3.838t3.975-1.562q2.325 0 4 1.563T17.7 14.65q0 .225-.137.363t-.363.137q-.2 0-.35-.137t-.15-.363q0-1.875-1.388-3.137t-3.287-1.263q-1.9 0-3.262 1.263T7.4 14.65q0 2.025.7 3.438t2.05 2.837q.15.15.15.35t-.15.35q-.15.15-.35.15t-.35-.15m7.55-1.7q-2.225 0-3.863-1.5T11.5 14.65q0-.2.138-.35t.362-.15q.225 0 .363.15t.137.35q0 1.875 1.35 3.075t3.15 1.2q.15 0 .425-.025t.575-.075q.225-.05.388.063t.212.337q.05.2-.075.35t-.325.2q-.45.125-.787.138t-.413.012"
></path>
</svg>
Add Passkey
</div>
}
>
<Loader />
</Show>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function ListPasskeys() {
const passkeys = useListPasskeys();
const [isDeletePasskey, setIsDeletePasskey] = createSignal(false);
return (
<Dialog>
<DialogTrigger>
<Button variant="outline">
Passkeys{" "}
{passkeys().data?.length ? `[${passkeys().data?.length}]` : ""}
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Passkeys</DialogTitle>
<DialogDescription>List of passkeys</DialogDescription>
</DialogHeader>
{passkeys().data?.length ? (
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{passkeys().data?.map((passkey) => (
<TableRow class="flex justify-between items-center">
<TableCell>{passkey.name || "My Passkey"}</TableCell>
<TableCell class="text-right">
<button
onClick={async () => {
const res = await passkeyActions.deletePasskey({
id: passkey.id,
fetchOptions: {
onRequest: () => {
setIsDeletePasskey(true);
},
onSuccess: () => {
alert("Passkey deleted successfully");
setIsDeletePasskey(false);
},
onError: (error) => {
alert(error.error.message);
setIsDeletePasskey(false);
},
},
});
}}
>
<Show
when={isDeletePasskey()}
fallback={
<svg
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
viewBox="0 0 24 24"
>
<path
fill="currentColor"
d="M5 21V6H4V4h5V3h6v1h5v2h-1v15zm2-2h10V6H7zm2-2h2V8H9zm4 0h2V8h-2zM7 6v13z"
></path>
</svg>
}
>
<Loader />
</Show>
</button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
) : (
<p class="text-sm text-muted-foreground">No passkeys found</p>
)}
</DialogContent>
</Dialog>
);
}
function TwoFactorDialog(props: { enabled?: boolean }) {
const [isOpen, setIsOpen] = createSignal(false);
const [password, setPassword] = createSignal<string>();
const [isLoading, setIsLoading] = createSignal(false);
return (
<Dialog onOpenChange={setIsOpen} open={isOpen()}>
<DialogTrigger>
<Button variant="secondary">
{props.enabled ? "Disable 2FA" : "Enable 2FA"}
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Enable Two Factor</DialogTitle>
<DialogDescription>
Enable two factor authentication
</DialogDescription>
</DialogHeader>
<div class="grid gap-2">
<TextFieldRoot>
<TextFieldLabel for="password">Password</TextFieldLabel>
<TextField
type="password"
placeholder="Password"
value={password()}
onInput={(e) => {
if ("value" in e.target) setPassword(e.target.value as string);
}}
/>
</TextFieldRoot>
</div>
<DialogFooter>
<Button
onClick={async () => {
if (!password()) {
alert("Password is required!");
}
setIsLoading(true);
if (props.enabled) {
await twoFactorActions.disable({
password: password()!,
fetchOptions: {
onResponse(context) {
setIsLoading(false);
},
onError(context) {
alert(context.error.message);
},
onSuccess() {
alert("Two factor is disabled!");
setIsOpen(false);
},
},
});
return;
}
await twoFactorActions.enable({
password: password()!,
fetchOptions: {
onResponse(context) {
setIsLoading(false);
},
onError(context) {
alert(context.error.message);
},
onSuccess() {
alert("Two factor successfully enabled!");
setIsOpen(false);
},
},
});
}}
>
<Show
fallback={<p>{props.enabled ? "Disable" : "Enable"}</p>}
when={isLoading()}
>
<Loader />
</Show>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,52 @@
---
import "../app.css";
---
<script is:inline>
const getThemePreference = () => {
if (
typeof localStorage !== "undefined" &&
localStorage.getItem("theme")
) {
return localStorage.getItem("theme");
}
return window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
};
const setColorMode = () => {
const isDark = getThemePreference() === "dark";
document.documentElement.classList[isDark ? "add" : "remove"]("dark");
};
if (typeof localStorage !== "undefined") {
const observer = new MutationObserver(() => {
const isDark = document.documentElement.classList.contains("dark");
localStorage.setItem("theme", isDark ? "dark" : "light");
});
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"],
});
}
setColorMode();
document.addEventListener("astro:after-swap", setColorMode);
</script>
<html>
<meta charset="utf-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width" />
<meta name="generator" content={Astro.generator} />
<title>Astro</title>
</head>
<body class="w-full h-screen flex-col items-center flex justify-center">
<div class="w-1/2 flex justify-center">
<slot />
</div>
</body>
</html>

View File

@@ -1,3 +0,0 @@
import { createAuthClient } from "better-auth/client";
export const { signIn, signOut, useSession } = createAuthClient();

View File

@@ -0,0 +1,32 @@
import { passkeyClient, twoFactorClient } from "better-auth/client/plugins";
import { createAuthClient } from "better-auth/solid";
import { createAuthClient as createVanillaClient } from "better-auth/client";
export const {
signIn,
signOut,
useSession,
signUp,
passkey: passkeyActions,
useListPasskeys,
user: userActions,
twoFactor: twoFactorActions,
$Infer,
} = createAuthClient({
baseURL:
process.env.NODE_ENV === "development"
? "http://localhost:3000"
: undefined,
plugins: [
passkeyClient(),
twoFactorClient({
twoFactorPage: "/two-factor",
}),
],
});
export const { useSession: useVanillaSession } = createVanillaClient({
baseURL:
process.env.NODE_ENV === "development"
? "http://localhost:3000"
: undefined,
});

View File

@@ -0,0 +1,5 @@
import type { ClassValue } from "clsx";
import clsx from "clsx";
import { twMerge } from "tailwind-merge";
export const cn = (...classLists: ClassValue[]) => twMerge(clsx(classLists));

View File

@@ -0,0 +1,3 @@
import type { $Infer } from "./auth-client";
export type ActiveSession = typeof $Infer.Session;

View File

@@ -0,0 +1,8 @@
export async function convertImageToBase64(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => resolve(reader.result as string);
reader.onerror = reject;
reader.readAsDataURL(file);
});
}

View File

@@ -0,0 +1,17 @@
import { auth } from "@/auth";
import { defineMiddleware } from "astro:middleware";
// `context` and `next` are automatically typed
export const onRequest = defineMiddleware(async (context, next) => {
const isAuthed = await auth.api
.getSession({
headers: context.request.headers,
})
.catch((e) => {
return null;
});
if (context.url.pathname === "/dashboard" && !isAuthed) {
return context.redirect("/");
}
return next();
});

View File

@@ -1,5 +1,5 @@
import type { APIRoute } from "astro";
import { auth } from "../../../lib/auth";
import { auth } from "../../../auth";
export const GET: APIRoute = async (ctx) => {
return auth.handler(ctx.request);

View File

@@ -0,0 +1,38 @@
---
import { UserCard } from "@/components/user-card";
import RootLayout from "@/layouts/root-layout.astro";
import { auth } from "@/auth";
const activeSessions = await auth.api
.listSessions({
headers: Astro.request.headers,
})
.catch((e) => {
return [];
});
const session = await auth.api.getSession({
headers: Astro.request.headers,
});
---
<RootLayout>
<UserCard activeSessions={activeSessions} initialSession={session} client:only/>
<button
id="login"
class="bg-black mt-3 flex items-center gap-2 rounded-sm py-2 px-3 text-white text-sm"
>
Sign In
</button>
<script>
import { useVanillaSession} from "../libs/auth-client";
useVanillaSession.subscribe((val) => {
if (val.data) {
document.getElementById("login")!.style.display = "none";
} else {
if(val.error){
document.getElementById("login")!.style.display = "flex";
}
}
});
</script>
</RootLayout>

View File

@@ -1,22 +1,10 @@
---
import RootLayout from "@/layouts/root-layout.astro";
---
---
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width" />
<meta name="generator" content={Astro.generator} />
<title>Astro</title>
</head>
<body>
<div
class="min-h-[80vh] flex flex-col items-center justify-center overflow-hidden no-visible-scrollbar px-6 md:px-0"
<div
class="flex flex-col gap-2"
>
<h3 class="font-bold text-4xl text-black dark:text-white text-center">
<RootLayout>
<div class="min-h-[80vh] flex flex-col items-center justify-center overflow-hidden no-visible-scrollbar px-6 md:px-0">
<h3 class="font-bold text-xl text-black dark:text-white text-center">
Better Auth.
</h3>
<p class="text-center break-words text-sm md:text-base">
@@ -30,62 +18,36 @@
</a>{" "}
example. <br />
</p>
<div id="user" class="w-1/2 flex gap-2 justify-center items-center mt-2">
<img src="" alt="" id="img" class="w-12 h-12">
<div>
<p id="name">
</p>
<p id="email">
</p>
</div>
</div>
<a href="/sign-in">
<button
id="login"
class="bg-black mt-3 flex items-center gap-2 rounded-sm py-2 px-3 text-white text-sm"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="22"
height="22"
viewBox="0 0 24 24"
><path
fill="currentColor"
d="M3.064 7.51A10 10 0 0 1 12 2c2.695 0 4.959.991 6.69 2.605l-2.867 2.868C14.786 6.482 13.468 5.977 12 5.977c-2.605 0-4.81 1.76-5.595 4.123c-.2.6-.314 1.24-.314 1.9s.114 1.3.314 1.9c.786 2.364 2.99 4.123 5.595 4.123c1.345 0 2.49-.355 3.386-.955a4.6 4.6 0 0 0 1.996-3.018H12v-3.868h9.418c.118.654.182 1.336.182 2.045c0 3.046-1.09 5.61-2.982 7.35C16.964 21.105 14.7 22 12 22A9.996 9.996 0 0 1 2 12c0-1.614.386-3.14 1.064-4.49"
></path></svg
Sign In
</button>
</a>
<a href="/dashboard">
<button
id="dashboard"
class="bg-black mt-3 flex items-center gap-2 rounded-sm py-2 px-3 text-white text-sm"
>
Continue with Google
</button>
<button id="sign-out" class="bg-black mt-3 flex items-center gap-2 rounded-sm py-2 px-3 text-white text-sm">
Sign Out
Dashboard
</button>
</a>
</div>
</body>
</html>
<script>
import { signIn, useSession, signOut } from "../lib/auth-client";
document.getElementById("login")!.addEventListener("click", () => {
signIn.social({
provider: "google",
});
});
useSession.get();
useSession.subscribe((val) => {
<script>
import { useVanillaSession } from "../libs/auth-client";
useVanillaSession.subscribe((val) => {
if (val.data) {
document.getElementById("name")!.textContent = val.data?.user.name || "";
document.getElementById("email")!.textContent = val.data?.user.email || "";
document.getElementById("img")!.src = val.data?.user.image || "";
document.getElementById("login")!.style.display = "none";
document.getElementById("dashboard")!.style.display = "flex";
} else {
if(val.error){
document.getElementById("login")!.style.display = "flex";
document.getElementById("sign-out")!.style.display = "none";
document.getElementById("img")!.style.display = "none";
document.getElementById("name")!.textContent = "";
document.getElementById("email")!.textContent = "";
document.getElementById("dashboard")!.style.display = "none";
}
}
});
document.getElementById("sign-out")!.addEventListener("click", () => {
signOut();
});
</script>
</script>
</RootLayout>

View File

@@ -0,0 +1,8 @@
---
import RootLayout from "@/layouts/root-layout.astro";
import { SignInCard } from "../components/sign-in";
---
<RootLayout>
<SignInCard client:load />
</RootLayout>

View File

@@ -0,0 +1,9 @@
---
import RootLayout from "@/layouts/root-layout.astro";
import { SignUpCard } from "../components/sign-up";
---
<RootLayout>
<SignUpCard client:load />
</RootLayout>

View File

@@ -0,0 +1,9 @@
---
import RootLayout from "@/layouts/root-layout.astro";
import { TwoFactorComponent } from "@/components/two-factor";
---
<RootLayout>
<TwoFactorComponent client:load />
</RootLayout>

View File

@@ -0,0 +1,10 @@
---
import RootLayout from "@/layouts/root-layout.astro";
import { TwoFactorEmail } from "@/components/two-factor";
---
<RootLayout>
<TwoFactorEmail client:load />
</RootLayout>

View File

@@ -1,8 +1,87 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}'],
module.exports = {
darkMode: ["class", '[data-kb-theme="dark"]'],
content: ['./src/**/*.{tsx,astro}',],
prefix: "",
theme: {
extend: {},
container: {
center: true,
padding: "2rem",
screens: {
"2xl": "1400px",
},
plugins: [],
}
},
extend: {
colors: {
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
keyframes: {
"accordion-down": {
from: { height: 0 },
to: { height: "var(--kb-accordion-content-height)" },
},
"accordion-up": {
from: { height: "var(--kb-accordion-content-height)" },
to: { height: 0 },
},
"collapsible-down": {
from: { height: 0 },
to: { height: "var(--kb-collapsible-content-height)" },
},
"collapsible-up": {
from: { height: "var(--kb-collapsible-content-height)" },
to: { height: 0 },
},
"caret-blink": {
"0%,70%,100%": { opacity: "1" },
"20%,50%": { opacity: "0" }
}
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
"collapsible-down": "collapsible-down 0.2s ease-out",
"collapsible-up": "collapsible-up 0.2s ease-out",
"caret-blink": "caret-blink 1.25s ease-out infinite"
},
},
},
plugins: [require("tailwindcss-animate")],
};

View File

@@ -1,3 +1,11 @@
{
"extends": "astro/tsconfigs/strict"
"extends": "astro/tsconfigs/strict",
"compilerOptions": {
"jsx": "preserve",
"jsxImportSource": "solid-js",
"baseUrl": "./",
"paths": {
"@/*": ["./src/*"]
}
}
}

View File

@@ -81,7 +81,7 @@
"@simplewebauthn/browser": "^10.0.0",
"@simplewebauthn/server": "^10.0.1",
"arctic": "2.0.0-next.9",
"better-call": "0.2.3-beta.8",
"better-call": "0.2.3-beta.10",
"better-sqlite3": "^11.1.2",
"c12": "^1.11.2",
"chalk": "^5.3.0",
@@ -89,6 +89,7 @@
"consola": "^3.2.3",
"defu": "^6.1.4",
"dotenv": "^16.4.5",
"i": "^0.3.7",
"jose": "^5.7.0",
"kysely": "^0.27.4",
"kysely-postgres-js": "^2.0.0",

View File

@@ -33,6 +33,24 @@ export const getAuthTables = (options: BetterAuthOptions) => {
>,
);
const shouldAddRateLimitTable = options.rateLimit?.storage === "database";
const rateLimitTable = {
rateLimit: {
tableName: options.rateLimit?.tableName || "rateLimit",
fields: {
key: {
type: "string",
},
count: {
type: "number",
},
lastRequest: {
type: "number",
},
},
},
} satisfies BetterAuthDbSchema;
const { user, session, account, ...pluginTables } = pluginSchema || {};
return {
@@ -133,5 +151,6 @@ export const getAuthTables = (options: BetterAuthOptions) => {
order: 2,
},
...pluginTables,
...(shouldAddRateLimitTable ? rateLimitTable : {}),
} satisfies BetterAuthDbSchema;
};

View File

@@ -30,6 +30,14 @@ import { logger } from "../utils/logger";
import { changePassword, updateUser } from "./routes/update-user";
import type { BetterAuthPlugin } from "../plugins";
import chalk from "chalk";
import { getIp } from "../utils";
import {
getRateLimitStorage,
getRetryAfter,
onRequestRateLimit,
rateLimitResponse,
shouldRateLimit,
} from "./rate-limiter";
export function getEndpoints<
C extends AuthContext,
@@ -167,6 +175,9 @@ export const router = <C extends AuthContext, Option extends BetterAuthOptions>(
},
...middlewares,
],
async onRequest(req) {
return onRequestRateLimit(req, ctx);
},
onError(e) {
const log = options.verboseLog ? logger : undefined;
if (options.disableLog !== true) {
@@ -180,7 +191,7 @@ export const router = <C extends AuthContext, Option extends BetterAuthOptions>(
return;
}
if (errorMessage.includes("no such table")) {
log?.error(
logger?.error(
`Please run ${chalk.green(
"npx better-auth migrate",
)} to create the tables. There are missing tables in your SQLite database.`,
@@ -198,7 +209,7 @@ export const router = <C extends AuthContext, Option extends BetterAuthOptions>(
errorMessage.includes("Table") &&
errorMessage.includes("doesn't exist")
) {
log?.error(
logger?.error(
`Please run ${chalk.green(
"npx better-auth migrate",
)} to create the tables. There are missing tables in your MySQL database.`,

View File

@@ -0,0 +1,79 @@
import { describe, it, expect, vi } from "vitest";
import { getTestInstance } from "../test-utils/test-instance";
describe("rate-limiter", async () => {
const { client, testUser } = await getTestInstance({
rateLimit: {
window: 10,
max: 20,
},
});
it("should return 429 after 7 request for sign-in", async () => {
for (let i = 0; i < 10; i++) {
const response = await client.signIn.email({
email: testUser.email,
password: testUser.password,
});
if (i >= 7) {
expect(response.error?.status).toBe(429);
} else {
expect(response.error).toBeNull();
}
}
});
it("should reset the limit after the window period", async () => {
vi.useFakeTimers();
vi.advanceTimersByTime(11000);
for (let i = 0; i < 10; i++) {
const res = await client.signIn.email({
email: testUser.email,
password: testUser.password,
});
if (i >= 7) {
expect(res.error?.status).toBe(429);
} else {
expect(res.error).toBeNull();
}
}
});
it("should respond the correct retry-after header", async () => {
vi.useFakeTimers();
vi.advanceTimersByTime(3000);
let retryAfter = "";
await client.signIn.email({
email: testUser.email,
password: testUser.password,
fetchOptions: {
onError(context) {
retryAfter = context.response.headers.get("X-Retry-After") ?? "";
},
},
});
expect(retryAfter).toBe("7");
});
it("should rate limit based on the path", async () => {
const signInRes = await client.signIn.email({
email: testUser.email,
password: testUser.password,
});
expect(signInRes.error?.status).toBe(429);
const signUpRes = await client.signUp.email({
email: "new-test@email.com",
password: testUser.password,
name: "test",
});
expect(signUpRes.error).toBeNull();
});
it("non-special-rules limits", async () => {
for (let i = 0; i < 25; i++) {
const response = await client.session();
expect(response.error?.status).toBe(i >= 20 ? 429 : 401);
}
});
});

View File

@@ -0,0 +1,177 @@
import type { AuthContext, RateLimit } from "../types";
import { getIp, logger } from "../utils";
export function shouldRateLimit(
max: number,
window: number,
rateLimitData: RateLimit,
) {
const now = Date.now();
const windowInMs = window * 1000;
const timeSinceLastRequest = now - rateLimitData.lastRequest;
return timeSinceLastRequest < windowInMs && rateLimitData.count >= max;
}
export function rateLimitResponse(retryAfter: number) {
return new Response(
JSON.stringify({
message: "Too many requests. Please try again later.",
}),
{
status: 429,
statusText: "Too Many Requests",
headers: {
"X-Retry-After": retryAfter.toString(),
},
},
);
}
export function getRetryAfter(lastRequest: number, window: number) {
const now = Date.now();
const windowInMs = window * 1000;
return Math.ceil((lastRequest + windowInMs - now) / 1000);
}
function createDBStorage(ctx: AuthContext, tableName?: string) {
const db = ctx.db;
return {
get: async (key: string) => {
const result = await db
.selectFrom(tableName ?? "rateLimit")
.where("key", "=", key)
.selectAll()
.executeTakeFirst();
return result as RateLimit | undefined;
},
set: async (key: string, value: RateLimit, _update?: boolean) => {
try {
if (_update) {
await db
.updateTable(tableName ?? "rateLimit")
.set({
count: value.count,
lastRequest: value.lastRequest,
})
.where("key", "=", key)
.execute();
} else {
await db
.insertInto(tableName ?? "rateLimit")
.values({
key,
count: value.count,
lastRequest: value.lastRequest,
})
.execute();
}
} catch (e) {
logger.error("Error setting rate limit", e);
}
},
};
}
const memory = new Map<string, RateLimit>();
export function getRateLimitStorage(ctx: AuthContext) {
if (ctx.rateLimit.customStorage) {
return ctx.rateLimit.customStorage;
}
const storage = ctx.rateLimit.storage;
if (storage === "memory") {
return {
async get(key: string) {
return memory.get(key);
},
async set(key: string, value: RateLimit, _update?: boolean) {
memory.set(key, value);
},
};
}
return createDBStorage(ctx, ctx.rateLimit.tableName);
}
export async function onRequestRateLimit(req: Request, ctx: AuthContext) {
if (!ctx.rateLimit.enabled) {
return;
}
const baseURL = ctx.baseURL;
const path = req.url.replace(baseURL, "");
let window = ctx.rateLimit.window;
let max = ctx.rateLimit.max;
const key = getIp(req) + path;
const specialRules = getDefaultSpecialRules();
const specialRule = specialRules.find((rule) => rule.pathMatcher(path));
if (specialRule) {
window = specialRule.window;
max = specialRule.max;
}
for (const plugin of ctx.options.plugins || []) {
if (plugin.rateLimit) {
const matchedRule = plugin.rateLimit.find((rule) =>
rule.pathMatcher(path),
);
if (matchedRule) {
window = matchedRule.window;
max = matchedRule.max;
break;
}
}
}
if (ctx.rateLimit.customRules) {
const customRule = ctx.rateLimit.customRules[path];
if (customRule) {
window = customRule.window;
max = customRule.max;
}
}
const storage = getRateLimitStorage(ctx);
const data = await storage.get(key);
const now = Date.now();
if (!data) {
await storage.set(key, {
key,
count: 1,
lastRequest: now,
});
} else {
const timeSinceLastRequest = now - data.lastRequest;
if (shouldRateLimit(max, window, data)) {
const retryAfter = getRetryAfter(data.lastRequest, window);
return rateLimitResponse(retryAfter);
} else if (timeSinceLastRequest > window * 1000) {
// Reset the count if the window has passed since the last request
await storage.set(key, {
...data,
count: 1,
lastRequest: now,
});
} else {
await storage.set(key, {
...data,
count: data.count + 1,
lastRequest: now,
});
}
}
}
function getDefaultSpecialRules() {
const specialRules = [
{
pathMatcher(path: string) {
return path.startsWith("/sign-in") || path.startsWith("/sign-up");
},
window: 10,
max: 7,
},
];
return specialRules;
}

View File

@@ -113,7 +113,9 @@ export const signInEmail = createAuthEndpoint(
},
async (ctx) => {
if (!ctx.context.options?.emailAndPassword?.enabled) {
ctx.context.logger.error("Email and password is not enabled");
ctx.context.logger.error(
"Email and password is not enabled. Make sure to enable it in the options on you `auth.ts` file. Check `https://better-auth.com/docs/authentication/email-password` for more!",
);
throw new APIError("BAD_REQUEST", {
message: "Email and password is not enabled",
});

View File

@@ -6,11 +6,11 @@ export const signOut = createAuthEndpoint(
"/sign-out",
{
method: "POST",
body: z
.object({
body: z.optional(
z.object({
callbackURL: z.string().optional(),
})
.optional(),
}),
),
},
async (ctx) => {
const sessionCookieToken = await ctx.getSignedCookie(

View File

@@ -3,8 +3,6 @@ import { getEndpoints, router } from "./api";
import { init } from "./init";
import type { BetterAuthOptions } from "./types/options";
import type { InferPluginTypes, InferSession, InferUser } from "./types";
import type { BetterAuthPlugin } from "./plugins";
import type { UnionToIntersection } from "./types/helper";
type InferAPI<API> = Omit<
API,

View File

@@ -91,7 +91,7 @@ export function createDynamicPathProxy<T extends Record<string, any>>(
setTimeout(() => {
//@ts-expect-error
signal.set(!val);
}, 0);
}, 10);
},
});
},

View File

@@ -1,5 +1,5 @@
export class BetterAuthError extends Error {
constructor(message: string, cause?: string) {
constructor(message: string, cause?: string, docsLink?: string) {
super(message);
this.name = "BetterAuthError";
this.message = message;

View File

@@ -62,6 +62,14 @@ export const init = (options: BetterAuthOptions) => {
expiresIn: options.session?.expiresIn || 60 * 60 * 24 * 7, // 7 days
},
secret,
rateLimit: {
...options.rateLimit,
enabled:
options.rateLimit?.enabled ?? process.env.NODE_ENV !== "development",
window: options.rateLimit?.window || 60,
max: options.rateLimit?.max || 100,
storage: options.rateLimit?.storage || "memory",
},
authCookies: cookies,
logger: createLogger({
disabled: options.disableLog,
@@ -85,6 +93,12 @@ export type AuthContext = {
authCookies: BetterAuthCookies;
logger: ReturnType<typeof createLogger>;
db: Kysely<any>;
rateLimit: {
enabled: boolean;
window: number;
max: number;
storage: "memory" | "database";
} & BetterAuthOptions["rateLimit"];
adapter: ReturnType<typeof getAdapter>;
internalAdapter: ReturnType<typeof createInternalAdapter>;
createAuthCookie: ReturnType<typeof createCookieGetter>;

View File

@@ -28,6 +28,7 @@ export const getPasskeyActions = (
autoFill?: boolean;
email?: string;
callbackURL?: string;
fetchOptions?: BetterFetchOption;
}) => {
const response = await $fetch<PublicKeyCredentialRequestOptionsJSON>(
"/passkey/generate-authenticate-options",
@@ -54,6 +55,7 @@ export const getPasskeyActions = (
body: {
response: res,
},
...opts?.fetchOptions,
});
if (!verified.data) {
return verified;
@@ -64,7 +66,7 @@ export const getPasskeyActions = (
};
const registerPasskey = async (opts?: {
options?: BetterFetchOption;
fetchOptions?: BetterFetchOption;
/**
* The name of the passkey. This is used to
* identify the passkey in the UI.
@@ -75,7 +77,6 @@ export const getPasskeyActions = (
"/passkey/generate-register-options",
{
method: "GET",
...opts?.options,
},
);
if (!options.data) {
@@ -86,7 +87,7 @@ export const getPasskeyActions = (
const verified = await $fetch<{
passkey: Passkey;
}>("/passkey/verify-registration", {
...opts?.options,
...opts?.fetchOptions,
body: {
response: res,
name: opts?.name,
@@ -127,7 +128,6 @@ export const getPasskeyActions = (
},
};
}
logger.error(e, "passkey registration error");
return {
data: null,
error: {

View File

@@ -345,7 +345,6 @@ export const passkey = (options?: PasskeyOptions) => {
status: 400,
});
}
console.log({ challengeString });
const { expectedChallenge, callbackURL } = JSON.parse(
challengeString,
) as WebAuthnCookieType;
@@ -409,7 +408,6 @@ export const passkey = (options?: PasskeyOptions) => {
ctx.request,
);
await setSessionCookie(ctx, s.id);
if (callbackURL) {
return ctx.json({
url: callbackURL,

View File

@@ -1,26 +0,0 @@
import { getSession } from "../../api/routes";
import { BetterAuthError } from "../../error/better-auth-error";
import { getIp } from "../../utils/get-request-ip";
import { logger } from "../../utils/logger";
export async function getRateLimitKey(req: Request) {
if (req.headers.get("Authorization") || req.headers.get("cookie")) {
try {
const session = await getSession()({
headers: req.headers,
// @ts-ignore
_flag: undefined,
});
if (session) {
return session.user.id;
}
} catch (e) {
return "";
}
}
const ip = getIp(req);
if (!ip) {
throw new BetterAuthError("IP not found");
}
return ip;
}

View File

@@ -1,270 +0,0 @@
import { APIError } from "better-call";
import { createAuthMiddleware } from "../../api/call";
import type { GenericEndpointContext } from "../../types/context";
import type { BetterAuthPlugin } from "../../types/plugins";
import { getRateLimitKey } from "./get-key";
import { logger } from "../../utils/logger";
interface RateLimit {
key: string;
count: number;
lastRequest: number;
}
export interface RateLimitOptions {
/**
* Enable rate limiting. You can also pass a function
* to enable rate limiting for specific endpoints.
*
* @default true
*/
enabled: boolean | ((request: Request) => boolean | Promise<boolean>);
/**
* The window to use for rate limiting. The value
* should be in seconds.
* @default 15 minutes (15 * 60)
*/
window?: number;
/**
* The maximum number of requests allowed within the window.
* @default 100
*/
max?: number;
/**
* Function to get the key to use for rate limiting.
* @default "ip" or "userId" if the user is logged in.
*/
getKey?: (request: Request) => string | Promise<string>;
storage?: {
custom?: {
get: (key: string) => Promise<RateLimit | undefined>;
set: (key: string, value: RateLimit) => Promise<void>;
};
/**
* The provider to use for rate limiting.
* @default "database"
*/
provider?: "database" | "memory" | "custom";
/**
* The name of the table to use for rate limiting. Only used if provider is "database".
* @default "rateLimit"
*/
tableName?: string;
};
/**
* Custom rate limiting function.
*/
customRateLimit?: (request: Request) => Promise<boolean>;
/**
* Special rules to apply to specific paths.
*
* By default, endpoints that starts with "/sign-in" or "/sign-up" are added
* to the rate limiting mechanism with a count value of 2.
* @example
* ```ts
* specialRules: [
* {
* matcher: (request) => request.url.startsWith("/sign-in"),
* // This will half the amount of requests allowed for the sign-in endpoint
* countValue: 2,
* }
* ]
* ```
*/
specialRules?: {
/**
* Custom matcher to determine if the special rule should be applied.
*/
matcher: (path: string) => boolean;
/**
* The value to use for the count.
*
*/
countValue: number;
}[];
}
/**
* Rate limiting plugin for BetterAuth. It implements a simple rate limiting
* mechanism to prevent abuse. It can be configured to use a database, memory
* storage or a custom storage. It can also be configured to use a custom rate
* limiting function.
*
* @example
* ```ts
* const plugin = rateLimiter({
* enabled: true,
* window: 60,
* max: 100,
* });
* ```
*/
export const rateLimiter = (options: RateLimitOptions) => {
const opts = {
storage: {
provider: "database",
tableName: "rateLimit",
},
max: 100,
window: 15 * 60,
specialRules: [
{
matcher(path) {
return path.startsWith("/sign-in") || path.startsWith("/sign-up");
},
countValue: 2,
},
],
...options,
} satisfies RateLimitOptions;
function createDBStorage(ctx: GenericEndpointContext) {
const db = ctx.context.db;
return {
get: async (key: string) => {
const result = await db
.selectFrom("rateLimit")
.where("key", "=", key)
.selectAll()
.executeTakeFirst();
return result as RateLimit | undefined;
},
set: async (key: string, value: RateLimit, isNew: boolean = true) => {
try {
if (isNew) {
await db
.insertInto(opts.storage.tableName ?? "rateLimit")
.values({
key,
count: value.count,
lastRequest: value.lastRequest,
})
.execute();
} else {
await db
.updateTable(opts.storage.tableName ?? "rateLimit")
.set({
count: value.count,
lastRequest: value.lastRequest,
})
.where("key", "=", key)
.execute();
}
} catch (e) {
logger.error("Error setting rate limit", e);
}
},
};
}
const storage = new Map<string, RateLimit>();
function createMemoryStorage() {
return {
get: async (key: string) => {
return storage.get(key);
},
set: async (key: string, value: RateLimit) => {
storage.set(key, value);
},
};
}
return {
id: "rate-limiter",
middlewares: [
{
path: "/**",
middleware: createAuthMiddleware(async (ctx) => {
if (!ctx.request) {
return;
}
if (opts.customRateLimit) {
const shouldLimit = await opts.customRateLimit(ctx.request);
if (!shouldLimit) {
throw new APIError("TOO_MANY_REQUESTS", {
message: "Too many requests",
});
}
return;
}
const key = await getRateLimitKey(ctx.request);
const storage = opts.storage.custom
? opts.storage.custom
: opts.storage.provider === "database"
? createDBStorage(ctx)
: createMemoryStorage();
const rateLimit = await storage.get(key);
if (!rateLimit) {
await storage.set(key, {
key,
count: 0,
lastRequest: new Date().getTime(),
});
return;
}
const now = new Date().getTime();
const windowStart = now - opts.window * 1000;
if (
rateLimit.lastRequest >= windowStart &&
rateLimit.count >= opts.max
) {
return new Response(
JSON.stringify({
message: "Too many requests. Please try again later.",
}),
{
status: 429,
statusText: "Too Many Requests",
headers: {
"X-RateLimit-Window": opts.window.toString(),
"X-RateLimit-Max": opts.max.toString(),
"X-RateLimit-Remaining": (
opts.max - rateLimit.count
).toString(),
"X-RateLimit-Reset": (
rateLimit.lastRequest +
opts.window * 1000 -
now
).toString(),
},
},
);
}
if (rateLimit.lastRequest < windowStart) {
rateLimit.count = 0;
}
const count =
opts.specialRules.find((rule) => rule.matcher(ctx.path))
?.countValue ?? 1;
await storage.set(
key,
{
key,
count: rateLimit.count + count,
lastRequest: now,
},
false,
);
return;
}),
},
],
schema: {
rateLimit: {
fields: {
key: {
type: "string",
},
count: {
type: "number",
},
lastRequest: {
type: "number",
},
},
disableMigration: opts.storage.provider !== "database",
},
},
} satisfies BetterAuthPlugin;
};

View File

@@ -1,57 +0,0 @@
import { describe, it, beforeAll, expect, vi } from "vitest";
import { getTestInstance } from "../../test-utils/test-instance";
import { rateLimiter } from ".";
describe("rate-limiter", async () => {
const { client, testUser } = await getTestInstance({
plugins: [
rateLimiter({
enabled: true,
storage: {
provider: "memory",
},
max: 10,
window: 10,
specialRules: [],
}),
],
});
it.only("should allow requests within the limit", async () => {
for (let i = 0; i < 10; i++) {
const response = await client.signIn.email({
email: testUser.email,
password: testUser.password,
});
if (i === 10) {
expect(response.error?.status).toBe(429);
} else {
expect(response.error).toBeNull();
}
}
});
it.only("should reset the limit after the window period", async () => {
vi.useFakeTimers();
// Make 10 requests to hit the limit
for (let i = 0; i < 10; i++) {
const res = await client.signIn.email({
email: testUser.email,
password: testUser.password,
});
if (res.error?.status === 429) {
break;
}
}
// Advance the timer by 11 seconds (just over the 10-second window)
vi.advanceTimersByTime(11000);
const response = await client.signIn.email({
email: testUser.email,
password: testUser.password,
});
expect(response.error).toBeNull();
vi.useRealTimers();
});
});

View File

@@ -244,6 +244,15 @@ export const twoFactor = (options?: TwoFactorOptions) => {
},
},
},
rateLimit: [
{
pathMatcher(path) {
return path.startsWith("/two-factor/");
},
window: 10,
max: 3,
},
],
} satisfies BetterAuthPlugin;
};

View File

@@ -50,7 +50,7 @@ export const otp2fa = (options?: OTPOptions) => {
async (ctx) => {
if (!options || !options.sendOTP) {
ctx.context.logger.error(
"otp isn't configured. please pass otp option on two factor plugin to enable otp",
"send otp isn't configured. Please configure the send otp function on otp options.",
);
throw new APIError("BAD_REQUEST", {
message: "otp isn't configured",

View File

@@ -83,4 +83,19 @@ export type InferPluginTypes<O extends BetterAuthOptions> =
>
: {};
export type { User, Session };
interface RateLimit {
/**
* The key to use for rate limiting
*/
key: string;
/**
* The number of requests made
*/
count: number;
/**
* The last request time in milliseconds
*/
lastRequest: number;
}
export type { User, Session, RateLimit };

View File

@@ -4,6 +4,7 @@ import type { FieldAttribute } from "../db/field";
import type { BetterAuthPlugin } from "./plugins";
import type { OAuthProviderList } from "./provider";
import type { SocialProviders } from "../social-providers";
import type { RateLimit } from "./models";
export interface BetterAuthOptions {
/**
@@ -215,4 +216,66 @@ export interface BetterAuthOptions {
* List of trusted origins.
*/
trustedOrigins?: string[];
/**
* Rate limiting configuration
*/
rateLimit?: {
/**
* By default, rate limiting is only
* enabled on production.
*/
enabled?: boolean;
/**
* Default window to use for rate limiting. The value
* should be in seconds.
*
* @default 60 sec
*/
window: number;
/**
* Custom rate limit rules to apply to
* specific paths.
*/
customRules?: {
[key: string]: {
/**
* The window to use for the custom rule.
*/
window: number;
/**
* The maximum number of requests allowed within the window.
*/
max: number;
};
};
/**
* The default maximum number of requests allowed within the window.
*
* @default 100
*/
max: number;
/**
* Storage configuration
*
* @default "memory"
*/
storage?: "memory" | "database";
/**
* If database is used as storage, the name of the table to
* use for rate limiting.
*
* @default "rateLimit"
*/
tableName?: string;
/**
* custom storage configuration.
*
* NOTE: If custom storage is used storage
* is ignored
*/
customStorage?: {
get: (key: string) => Promise<RateLimit | undefined>;
set: (key: string, value: RateLimit) => Promise<void>;
};
};
}

View File

@@ -82,4 +82,12 @@ export type BetterAuthPlugin = {
*/
options?: Record<string, any>;
$Infer?: Record<string, any>;
/**
* The rate limit rules to apply to specific paths.
*/
rateLimit?: {
window: number;
max: number;
pathMatcher: (path: string) => boolean;
}[];
};

1196
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -13,7 +13,7 @@
[ ] add a way to specify on the client to use the base path as the whole path
[x] implement magic links
[ ] rate limiter error handling
[ ] microsoft oauth
## Docs
[x] specify everywhere `auth` should be exported
@@ -23,3 +23,4 @@
[x] add a doc about account linking
[x] remove the section about using useSession in next with initialValue
[x] rate limiting docs
[ ] add that passkey login won't require 2fa