Compare commits
	
		
			4 Commits
		
	
	
		
			9a4fa73ef6
			...
			d02a07541b
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| d02a07541b | |||
| cad467d8f4 | |||
| 671c628117 | |||
| 30703be277 | 
							
								
								
									
										1252
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										1252
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							@ -16,10 +16,14 @@
 | 
				
			|||||||
    "import": "cd ./import/ && tsc ./parseLibraryThingExport.ts --module esnext && node ./parseLibraryThingExport.js"
 | 
					    "import": "cd ./import/ && tsc ./parseLibraryThingExport.ts --module esnext && node ./parseLibraryThingExport.js"
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
  "dependencies": {
 | 
					  "dependencies": {
 | 
				
			||||||
 | 
					    "@headlessui/react": "^2.2.1",
 | 
				
			||||||
 | 
					    "@heroicons/react": "^2.2.0",
 | 
				
			||||||
    "@payloadcms/db-postgres": "3.31.0",
 | 
					    "@payloadcms/db-postgres": "3.31.0",
 | 
				
			||||||
    "@payloadcms/next": "3.31.0",
 | 
					    "@payloadcms/next": "3.31.0",
 | 
				
			||||||
    "@payloadcms/payload-cloud": "3.31.0",
 | 
					    "@payloadcms/payload-cloud": "3.31.0",
 | 
				
			||||||
    "@payloadcms/richtext-lexical": "3.31.0",
 | 
					    "@payloadcms/richtext-lexical": "3.31.0",
 | 
				
			||||||
 | 
					    "@tailwindcss/cli": "^4.1.4",
 | 
				
			||||||
 | 
					    "@tailwindcss/postcss": "^4.1.4",
 | 
				
			||||||
    "cross-env": "^7.0.3",
 | 
					    "cross-env": "^7.0.3",
 | 
				
			||||||
    "graphql": "^16.8.1",
 | 
					    "graphql": "^16.8.1",
 | 
				
			||||||
    "next": "15.2.3",
 | 
					    "next": "15.2.3",
 | 
				
			||||||
@ -33,9 +37,12 @@
 | 
				
			|||||||
    "@types/node": "^22.5.4",
 | 
					    "@types/node": "^22.5.4",
 | 
				
			||||||
    "@types/react": "19.0.12",
 | 
					    "@types/react": "19.0.12",
 | 
				
			||||||
    "@types/react-dom": "19.0.4",
 | 
					    "@types/react-dom": "19.0.4",
 | 
				
			||||||
 | 
					    "autoprefixer": "^10.4.21",
 | 
				
			||||||
    "eslint": "^9.16.0",
 | 
					    "eslint": "^9.16.0",
 | 
				
			||||||
    "eslint-config-next": "15.2.3",
 | 
					    "eslint-config-next": "15.2.3",
 | 
				
			||||||
 | 
					    "postcss": "^8.5.3",
 | 
				
			||||||
    "prettier": "^3.4.2",
 | 
					    "prettier": "^3.4.2",
 | 
				
			||||||
 | 
					    "tailwindcss": "^4.1.4",
 | 
				
			||||||
    "typescript": "5.7.3"
 | 
					    "typescript": "5.7.3"
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
  "engines": {
 | 
					  "engines": {
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										6
									
								
								postcss.config.mjs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								postcss.config.mjs
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,6 @@
 | 
				
			|||||||
 | 
					const config = {
 | 
				
			||||||
 | 
					  plugins: {
 | 
				
			||||||
 | 
					    '@tailwindcss/postcss': {},
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					export default config
 | 
				
			||||||
							
								
								
									
										1
									
								
								public/images/book-48.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								public/images/book-48.svg
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1 @@
 | 
				
			|||||||
 | 
					<svg xmlns="http://www.w3.org/2000/svg"  viewBox="0 0 24 24" width="48px" height="48px"><path d="M20,2.75C20,2.336,19.664,2,19.25,2H7C5.346,2,4,3.346,4,5v14.5C4,20.879,5.122,22,6.5,22h12.75	c0.414,0,0.75-0.336,0.75-0.75s-0.336-0.75-0.75-0.75h-0.38c-0.156-0.193-0.37-0.531-0.37-1c0-0.47,0.216-0.809,0.37-1h0.38	c0.414,0,0.75-0.336,0.75-0.75c0-0.089-0.022-0.171-0.05-0.25c0.028-0.079,0.05-0.161,0.05-0.25V2.75z M8.75,6h6.5	C15.664,6,16,6.336,16,6.75S15.664,7.5,15.25,7.5h-6.5C8.336,7.5,8,7.164,8,6.75S8.336,6,8.75,6z M17.176,20.5H6.5	c-0.551,0-1-0.448-1-1s0.449-1,1-1h10.676C17.072,18.792,17,19.125,17,19.5S17.072,20.208,17.176,20.5z"/></svg>
 | 
				
			||||||
| 
		 After Width: | Height: | Size: 640 B  | 
@ -1,5 +1,4 @@
 | 
				
			|||||||
import React from 'react'
 | 
					import React from 'react'
 | 
				
			||||||
import './styles.css'
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const metadata = {
 | 
					export const metadata = {
 | 
				
			||||||
  description: 'A blank template using Payload in a Next.js app.',
 | 
					  description: 'A blank template using Payload in a Next.js app.',
 | 
				
			||||||
@ -12,7 +11,7 @@ export default async function RootLayout(props: { children: React.ReactNode }) {
 | 
				
			|||||||
  return (
 | 
					  return (
 | 
				
			||||||
    <html lang="en">
 | 
					    <html lang="en">
 | 
				
			||||||
      <body>
 | 
					      <body>
 | 
				
			||||||
        <main>{children}</main>
 | 
					        <main className="bg-white dark:bg-gray-800">{children}</main>
 | 
				
			||||||
      </body>
 | 
					      </body>
 | 
				
			||||||
    </html>
 | 
					    </html>
 | 
				
			||||||
  )
 | 
					  )
 | 
				
			||||||
 | 
				
			|||||||
@ -5,7 +5,8 @@ import React from 'react'
 | 
				
			|||||||
import { fileURLToPath } from 'url'
 | 
					import { fileURLToPath } from 'url'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import config from '@/payload.config'
 | 
					import config from '@/payload.config'
 | 
				
			||||||
import './styles.css'
 | 
					import BookList from '@/components/BookList'
 | 
				
			||||||
 | 
					import { Book } from '@/payload-types'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export default async function HomePage() {
 | 
					export default async function HomePage() {
 | 
				
			||||||
  const headers = await getHeaders()
 | 
					  const headers = await getHeaders()
 | 
				
			||||||
@ -15,6 +16,21 @@ export default async function HomePage() {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
  const fileURL = `vscode://file/${fileURLToPath(import.meta.url)}`
 | 
					  const fileURL = `vscode://file/${fileURLToPath(import.meta.url)}`
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const books = await payload.find({
 | 
				
			||||||
 | 
					    collection: 'books',
 | 
				
			||||||
 | 
					    depth: 2,
 | 
				
			||||||
 | 
					    limit: 12,
 | 
				
			||||||
 | 
					    overrideAccess: false,
 | 
				
			||||||
 | 
					    select: {
 | 
				
			||||||
 | 
					      title: true,
 | 
				
			||||||
 | 
					      authors: true,
 | 
				
			||||||
 | 
					      publication: true,
 | 
				
			||||||
 | 
					      lcc: true,
 | 
				
			||||||
 | 
					      genre: true,
 | 
				
			||||||
 | 
					      isbn: true,
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					  })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  return (
 | 
					  return (
 | 
				
			||||||
    <div className="home">
 | 
					    <div className="home">
 | 
				
			||||||
      <div className="content">
 | 
					      <div className="content">
 | 
				
			||||||
@ -48,6 +64,7 @@ export default async function HomePage() {
 | 
				
			|||||||
          </a>
 | 
					          </a>
 | 
				
			||||||
        </div>
 | 
					        </div>
 | 
				
			||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
 | 
					      <BookList books={(books.docs || []) as Book[]} />
 | 
				
			||||||
      <div className="footer">
 | 
					      <div className="footer">
 | 
				
			||||||
        <p>Update this page by editing</p>
 | 
					        <p>Update this page by editing</p>
 | 
				
			||||||
        <a className="codeLink" href={fileURL}>
 | 
					        <a className="codeLink" href={fileURL}>
 | 
				
			||||||
 | 
				
			|||||||
@ -1,164 +0,0 @@
 | 
				
			|||||||
:root {
 | 
					 | 
				
			||||||
  --font-mono: 'Roboto Mono', monospace;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
* {
 | 
					 | 
				
			||||||
  box-sizing: border-box;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
html {
 | 
					 | 
				
			||||||
  font-size: 18px;
 | 
					 | 
				
			||||||
  line-height: 32px;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  background: rgb(0, 0, 0);
 | 
					 | 
				
			||||||
  -webkit-font-smoothing: antialiased;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
html,
 | 
					 | 
				
			||||||
body,
 | 
					 | 
				
			||||||
#app {
 | 
					 | 
				
			||||||
  height: 100%;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
body {
 | 
					 | 
				
			||||||
  font-family: system-ui;
 | 
					 | 
				
			||||||
  font-size: 18px;
 | 
					 | 
				
			||||||
  line-height: 32px;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  margin: 0;
 | 
					 | 
				
			||||||
  color: rgb(1000, 1000, 1000);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  @media (max-width: 1024px) {
 | 
					 | 
				
			||||||
    font-size: 15px;
 | 
					 | 
				
			||||||
    line-height: 24px;
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
img {
 | 
					 | 
				
			||||||
  max-width: 100%;
 | 
					 | 
				
			||||||
  height: auto;
 | 
					 | 
				
			||||||
  display: block;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
h1 {
 | 
					 | 
				
			||||||
  margin: 40px 0;
 | 
					 | 
				
			||||||
  font-size: 64px;
 | 
					 | 
				
			||||||
  line-height: 70px;
 | 
					 | 
				
			||||||
  font-weight: bold;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  @media (max-width: 1024px) {
 | 
					 | 
				
			||||||
    margin: 24px 0;
 | 
					 | 
				
			||||||
    font-size: 42px;
 | 
					 | 
				
			||||||
    line-height: 42px;
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  @media (max-width: 768px) {
 | 
					 | 
				
			||||||
    font-size: 38px;
 | 
					 | 
				
			||||||
    line-height: 38px;
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  @media (max-width: 400px) {
 | 
					 | 
				
			||||||
    font-size: 32px;
 | 
					 | 
				
			||||||
    line-height: 32px;
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
p {
 | 
					 | 
				
			||||||
  margin: 24px 0;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  @media (max-width: 1024px) {
 | 
					 | 
				
			||||||
    margin: calc(var(--base) * 0.75) 0;
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
a {
 | 
					 | 
				
			||||||
  color: currentColor;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  &:focus {
 | 
					 | 
				
			||||||
    opacity: 0.8;
 | 
					 | 
				
			||||||
    outline: none;
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  &:active {
 | 
					 | 
				
			||||||
    opacity: 0.7;
 | 
					 | 
				
			||||||
    outline: none;
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
svg {
 | 
					 | 
				
			||||||
  vertical-align: middle;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.home {
 | 
					 | 
				
			||||||
  display: flex;
 | 
					 | 
				
			||||||
  flex-direction: column;
 | 
					 | 
				
			||||||
  justify-content: space-between;
 | 
					 | 
				
			||||||
  align-items: center;
 | 
					 | 
				
			||||||
  height: 100vh;
 | 
					 | 
				
			||||||
  padding: 45px;
 | 
					 | 
				
			||||||
  max-width: 1024px;
 | 
					 | 
				
			||||||
  margin: 0 auto;
 | 
					 | 
				
			||||||
  overflow: hidden;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  @media (max-width: 400px) {
 | 
					 | 
				
			||||||
    padding: 24px;
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  .content {
 | 
					 | 
				
			||||||
    display: flex;
 | 
					 | 
				
			||||||
    flex-direction: column;
 | 
					 | 
				
			||||||
    align-items: center;
 | 
					 | 
				
			||||||
    justify-content: center;
 | 
					 | 
				
			||||||
    flex-grow: 1;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    h1 {
 | 
					 | 
				
			||||||
      text-align: center;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  .links {
 | 
					 | 
				
			||||||
    display: flex;
 | 
					 | 
				
			||||||
    align-items: center;
 | 
					 | 
				
			||||||
    gap: 12px;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    a {
 | 
					 | 
				
			||||||
      text-decoration: none;
 | 
					 | 
				
			||||||
      padding: 0.25rem 0.5rem;
 | 
					 | 
				
			||||||
      border-radius: 4px;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    .admin {
 | 
					 | 
				
			||||||
      color: rgb(0, 0, 0);
 | 
					 | 
				
			||||||
      background: rgb(1000, 1000, 1000);
 | 
					 | 
				
			||||||
      border: 1px solid rgb(0, 0, 0);
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    .docs {
 | 
					 | 
				
			||||||
      color: rgb(1000, 1000, 1000);
 | 
					 | 
				
			||||||
      background: rgb(0, 0, 0);
 | 
					 | 
				
			||||||
      border: 1px solid rgb(1000, 1000, 1000);
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  .footer {
 | 
					 | 
				
			||||||
    display: flex;
 | 
					 | 
				
			||||||
    align-items: center;
 | 
					 | 
				
			||||||
    gap: 8px;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    @media (max-width: 1024px) {
 | 
					 | 
				
			||||||
      flex-direction: column;
 | 
					 | 
				
			||||||
      gap: 6px;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    p {
 | 
					 | 
				
			||||||
      margin: 0;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    .codeLink {
 | 
					 | 
				
			||||||
      text-decoration: none;
 | 
					 | 
				
			||||||
      padding: 0 0.5rem;
 | 
					 | 
				
			||||||
      background: rgb(60, 60, 60);
 | 
					 | 
				
			||||||
      border-radius: 4px;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
							
								
								
									
										5
									
								
								src/app/globals.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								src/app/globals.css
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,5 @@
 | 
				
			|||||||
 | 
					@import "tailwindcss";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@tailwind base;
 | 
				
			||||||
 | 
					@tailwind components;
 | 
				
			||||||
 | 
					@tailwind utilities;
 | 
				
			||||||
							
								
								
									
										17
									
								
								src/app/layout.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								src/app/layout.tsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,17 @@
 | 
				
			|||||||
 | 
					import { ReactNode } from 'react'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					type LayoutProps = {
 | 
				
			||||||
 | 
					  children: ReactNode
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import './globals.css'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const Layout = ({ children }: LayoutProps) => {
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <html>
 | 
				
			||||||
 | 
					      <body>{children}</body>
 | 
				
			||||||
 | 
					    </html>
 | 
				
			||||||
 | 
					  )
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export default Layout
 | 
				
			||||||
@ -1,4 +1,3 @@
 | 
				
			|||||||
import { access } from 'node:fs/promises'
 | 
					 | 
				
			||||||
import type { CollectionConfig } from 'payload'
 | 
					import type { CollectionConfig } from 'payload'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const Authors: CollectionConfig = {
 | 
					export const Authors: CollectionConfig = {
 | 
				
			||||||
 | 
				
			|||||||
@ -40,6 +40,14 @@ export const Books: CollectionConfig = {
 | 
				
			|||||||
      name: 'lcc',
 | 
					      name: 'lcc',
 | 
				
			||||||
      type: 'text',
 | 
					      type: 'text',
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					      name: 'isbn',
 | 
				
			||||||
 | 
					      type: 'text',
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					      name: 'asin',
 | 
				
			||||||
 | 
					      type: 'text',
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
      name: 'publication',
 | 
					      name: 'publication',
 | 
				
			||||||
      type: 'text',
 | 
					      type: 'text',
 | 
				
			||||||
@ -54,6 +62,7 @@ export const Books: CollectionConfig = {
 | 
				
			|||||||
      type: 'relationship',
 | 
					      type: 'relationship',
 | 
				
			||||||
      relationTo: 'genre',
 | 
					      relationTo: 'genre',
 | 
				
			||||||
      hasMany: true,
 | 
					      hasMany: true,
 | 
				
			||||||
 | 
					      maxDepth: 3,
 | 
				
			||||||
      admin: {
 | 
					      admin: {
 | 
				
			||||||
        allowEdit: true,
 | 
					        allowEdit: true,
 | 
				
			||||||
        allowCreate: true
 | 
					        allowCreate: true
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										84
									
								
								src/components/BookList/index.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										84
									
								
								src/components/BookList/index.tsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,84 @@
 | 
				
			|||||||
 | 
					import { Badge } from '@/components/badge'
 | 
				
			||||||
 | 
					import { Author, Book, Genre } from '@/payload-types'
 | 
				
			||||||
 | 
					import { Avatar } from '../avatar'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					type Props = {
 | 
				
			||||||
 | 
					  books: Book[]
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const makeAuthorsLabel = (book: Book) => {
 | 
				
			||||||
 | 
					  const authors = book.authors as Author[] // TODO: endure this type safety
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const translators = authors?.filter((a) => a.role === 'Translator').map((t) => t.lf)
 | 
				
			||||||
 | 
					  const editors = authors?.filter((a) => a.role === 'Editor').map((e) => e.lf)
 | 
				
			||||||
 | 
					  const actualAuthors = authors
 | 
				
			||||||
 | 
					    ?.filter((a) => !editors.includes(a.lf) && !translators.includes(a.lf))
 | 
				
			||||||
 | 
					    .map((a) => a.lf)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <ul>
 | 
				
			||||||
 | 
					      <li>{actualAuthors.join(', ')}</li>
 | 
				
			||||||
 | 
					      {!!translators?.length && <li>Translators: {translators.join(', ')}</li>}
 | 
				
			||||||
 | 
					      {!!editors?.length && <li>Editors: {editors.join(', ')}</li>}
 | 
				
			||||||
 | 
					    </ul>
 | 
				
			||||||
 | 
					  )
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const makeGenreBadges = (book: Book) => {
 | 
				
			||||||
 | 
					  return (book.genre as Genre[])?.map((g) => (
 | 
				
			||||||
 | 
					    <Badge key={g.name + book.title + book.id} className="ml-0.5">
 | 
				
			||||||
 | 
					      {g.name}
 | 
				
			||||||
 | 
					    </Badge>
 | 
				
			||||||
 | 
					  ))
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export default function BookList(props: Props) {
 | 
				
			||||||
 | 
					  const { books } = props
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <section id="user-repositories">
 | 
				
			||||||
 | 
					      <ul role="list" className="divide-y divide-gray-800">
 | 
				
			||||||
 | 
					        {books.map((b) => (
 | 
				
			||||||
 | 
					          <li key={b.lcc + (b.title || '')} className="flex justify-between gap-x-6 py-5">
 | 
				
			||||||
 | 
					            <div className="flex max-w-9/12 min-w-0 gap-x-4">
 | 
				
			||||||
 | 
					              <Avatar
 | 
				
			||||||
 | 
					                square
 | 
				
			||||||
 | 
					                className="size-12 flex-none  bg-gray-800"
 | 
				
			||||||
 | 
					                src={
 | 
				
			||||||
 | 
					                  b.isbn
 | 
				
			||||||
 | 
					                    ? `https://covers.openlibrary.org/b/isbn/${b.isbn}-S.jpg`
 | 
				
			||||||
 | 
					                    : '/images/book-48.svg'
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					              />
 | 
				
			||||||
 | 
					              <div className="min-w-0 flex-auto">
 | 
				
			||||||
 | 
					                <p className="text-sm/6 font-semibold text-white">
 | 
				
			||||||
 | 
					                  <span>{b.title}</span>
 | 
				
			||||||
 | 
					                  <small className="ml-1 italic font-thin">{b.lcc}</small>
 | 
				
			||||||
 | 
					                </p>
 | 
				
			||||||
 | 
					                <p className="mt-1 truncate text-xs/5 text-gray-400">{makeGenreBadges(b)}</p>
 | 
				
			||||||
 | 
					              </div>
 | 
				
			||||||
 | 
					            </div>
 | 
				
			||||||
 | 
					            <div className="flex flex-col items-end">
 | 
				
			||||||
 | 
					              <p className="text-sm/6 text-white text-right">{makeAuthorsLabel(b)}</p>
 | 
				
			||||||
 | 
					              {
 | 
				
			||||||
 | 
					                /*b.lastBorrowed*/ false ? (
 | 
				
			||||||
 | 
					                  <p className="mt-1 text-xs/5 text-gray-400">
 | 
				
			||||||
 | 
					                    Last Borrowed{' '}
 | 
				
			||||||
 | 
					                    <time dateTime={new Date().toUTCString()}>{new Date().toUTCString()}</time>
 | 
				
			||||||
 | 
					                  </p>
 | 
				
			||||||
 | 
					                ) : (
 | 
				
			||||||
 | 
					                  <div className="mt-1 flex items-center gap-x-1.5">
 | 
				
			||||||
 | 
					                    <div className="flex-none rounded-full bg-emerald-500/20 p-1">
 | 
				
			||||||
 | 
					                      <div className="size-1.5 rounded-full bg-emerald-500" />
 | 
				
			||||||
 | 
					                    </div>
 | 
				
			||||||
 | 
					                    <p className="text-xs/5 text-gray-400">Available</p>
 | 
				
			||||||
 | 
					                  </div>
 | 
				
			||||||
 | 
					                )
 | 
				
			||||||
 | 
					              }
 | 
				
			||||||
 | 
					            </div>
 | 
				
			||||||
 | 
					          </li>
 | 
				
			||||||
 | 
					        ))}
 | 
				
			||||||
 | 
					      </ul>
 | 
				
			||||||
 | 
					    </section>
 | 
				
			||||||
 | 
					  )
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										95
									
								
								src/components/alert.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										95
									
								
								src/components/alert.tsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,95 @@
 | 
				
			|||||||
 | 
					import * as Headless from '@headlessui/react'
 | 
				
			||||||
 | 
					import clsx from 'clsx'
 | 
				
			||||||
 | 
					import type React from 'react'
 | 
				
			||||||
 | 
					import { Text } from './text'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const sizes = {
 | 
				
			||||||
 | 
					  xs: 'sm:max-w-xs',
 | 
				
			||||||
 | 
					  sm: 'sm:max-w-sm',
 | 
				
			||||||
 | 
					  md: 'sm:max-w-md',
 | 
				
			||||||
 | 
					  lg: 'sm:max-w-lg',
 | 
				
			||||||
 | 
					  xl: 'sm:max-w-xl',
 | 
				
			||||||
 | 
					  '2xl': 'sm:max-w-2xl',
 | 
				
			||||||
 | 
					  '3xl': 'sm:max-w-3xl',
 | 
				
			||||||
 | 
					  '4xl': 'sm:max-w-4xl',
 | 
				
			||||||
 | 
					  '5xl': 'sm:max-w-5xl',
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function Alert({
 | 
				
			||||||
 | 
					  size = 'md',
 | 
				
			||||||
 | 
					  className,
 | 
				
			||||||
 | 
					  children,
 | 
				
			||||||
 | 
					  ...props
 | 
				
			||||||
 | 
					}: { size?: keyof typeof sizes; className?: string; children: React.ReactNode } & Omit<
 | 
				
			||||||
 | 
					  Headless.DialogProps,
 | 
				
			||||||
 | 
					  'as' | 'className'
 | 
				
			||||||
 | 
					>) {
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <Headless.Dialog {...props}>
 | 
				
			||||||
 | 
					      <Headless.DialogBackdrop
 | 
				
			||||||
 | 
					        transition
 | 
				
			||||||
 | 
					        className="fixed inset-0 flex w-screen justify-center overflow-y-auto bg-zinc-950/15 px-2 py-2 transition duration-100 focus:outline-0 data-closed:opacity-0 data-enter:ease-out data-leave:ease-in sm:px-6 sm:py-8 lg:px-8 lg:py-16 dark:bg-zinc-950/50"
 | 
				
			||||||
 | 
					      />
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      <div className="fixed inset-0 w-screen overflow-y-auto pt-6 sm:pt-0">
 | 
				
			||||||
 | 
					        <div className="grid min-h-full grid-rows-[1fr_auto_1fr] justify-items-center p-8 sm:grid-rows-[1fr_auto_3fr] sm:p-4">
 | 
				
			||||||
 | 
					          <Headless.DialogPanel
 | 
				
			||||||
 | 
					            transition
 | 
				
			||||||
 | 
					            className={clsx(
 | 
				
			||||||
 | 
					              className,
 | 
				
			||||||
 | 
					              sizes[size],
 | 
				
			||||||
 | 
					              'row-start-2 w-full rounded-2xl bg-white p-8 shadow-lg ring-1 ring-zinc-950/10 sm:rounded-2xl sm:p-6 dark:bg-zinc-900 dark:ring-white/10 forced-colors:outline',
 | 
				
			||||||
 | 
					              'transition duration-100 will-change-transform data-closed:opacity-0 data-enter:ease-out data-closed:data-enter:scale-95 data-leave:ease-in'
 | 
				
			||||||
 | 
					            )}
 | 
				
			||||||
 | 
					          >
 | 
				
			||||||
 | 
					            {children}
 | 
				
			||||||
 | 
					          </Headless.DialogPanel>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					    </Headless.Dialog>
 | 
				
			||||||
 | 
					  )
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function AlertTitle({
 | 
				
			||||||
 | 
					  className,
 | 
				
			||||||
 | 
					  ...props
 | 
				
			||||||
 | 
					}: { className?: string } & Omit<Headless.DialogTitleProps, 'as' | 'className'>) {
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <Headless.DialogTitle
 | 
				
			||||||
 | 
					      {...props}
 | 
				
			||||||
 | 
					      className={clsx(
 | 
				
			||||||
 | 
					        className,
 | 
				
			||||||
 | 
					        'text-center text-base/6 font-semibold text-balance text-zinc-950 sm:text-left sm:text-sm/6 sm:text-wrap dark:text-white'
 | 
				
			||||||
 | 
					      )}
 | 
				
			||||||
 | 
					    />
 | 
				
			||||||
 | 
					  )
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function AlertDescription({
 | 
				
			||||||
 | 
					  className,
 | 
				
			||||||
 | 
					  ...props
 | 
				
			||||||
 | 
					}: { className?: string } & Omit<Headless.DescriptionProps<typeof Text>, 'as' | 'className'>) {
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <Headless.Description
 | 
				
			||||||
 | 
					      as={Text}
 | 
				
			||||||
 | 
					      {...props}
 | 
				
			||||||
 | 
					      className={clsx(className, 'mt-2 text-center text-pretty sm:text-left')}
 | 
				
			||||||
 | 
					    />
 | 
				
			||||||
 | 
					  )
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function AlertBody({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) {
 | 
				
			||||||
 | 
					  return <div {...props} className={clsx(className, 'mt-4')} />
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function AlertActions({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) {
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <div
 | 
				
			||||||
 | 
					      {...props}
 | 
				
			||||||
 | 
					      className={clsx(
 | 
				
			||||||
 | 
					        className,
 | 
				
			||||||
 | 
					        'mt-6 flex flex-col-reverse items-center justify-end gap-3 *:w-full sm:mt-4 sm:flex-row sm:*:w-auto'
 | 
				
			||||||
 | 
					      )}
 | 
				
			||||||
 | 
					    />
 | 
				
			||||||
 | 
					  )
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										11
									
								
								src/components/auth-layout.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								src/components/auth-layout.tsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,11 @@
 | 
				
			|||||||
 | 
					import type React from 'react'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function AuthLayout({ children }: { children: React.ReactNode }) {
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <main className="flex min-h-dvh flex-col p-2">
 | 
				
			||||||
 | 
					      <div className="flex grow items-center justify-center p-6 lg:rounded-lg lg:bg-white lg:p-10 lg:shadow-xs lg:ring-1 lg:ring-zinc-950/5 dark:lg:bg-zinc-900 dark:lg:ring-white/10">
 | 
				
			||||||
 | 
					        {children}
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					    </main>
 | 
				
			||||||
 | 
					  )
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										84
									
								
								src/components/avatar.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										84
									
								
								src/components/avatar.tsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,84 @@
 | 
				
			|||||||
 | 
					import * as Headless from '@headlessui/react'
 | 
				
			||||||
 | 
					import clsx from 'clsx'
 | 
				
			||||||
 | 
					import React, { forwardRef } from 'react'
 | 
				
			||||||
 | 
					import { TouchTarget } from './button'
 | 
				
			||||||
 | 
					import { Link } from './link'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					type AvatarProps = {
 | 
				
			||||||
 | 
					  src?: string | null
 | 
				
			||||||
 | 
					  square?: boolean
 | 
				
			||||||
 | 
					  initials?: string
 | 
				
			||||||
 | 
					  alt?: string
 | 
				
			||||||
 | 
					  className?: string
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function Avatar({
 | 
				
			||||||
 | 
					  src = null,
 | 
				
			||||||
 | 
					  square = false,
 | 
				
			||||||
 | 
					  initials,
 | 
				
			||||||
 | 
					  alt = '',
 | 
				
			||||||
 | 
					  className,
 | 
				
			||||||
 | 
					  ...props
 | 
				
			||||||
 | 
					}: AvatarProps & React.ComponentPropsWithoutRef<'span'>) {
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <span
 | 
				
			||||||
 | 
					      data-slot="avatar"
 | 
				
			||||||
 | 
					      {...props}
 | 
				
			||||||
 | 
					      className={clsx(
 | 
				
			||||||
 | 
					        className,
 | 
				
			||||||
 | 
					        // Basic layout
 | 
				
			||||||
 | 
					        'inline-grid shrink-0 align-middle [--avatar-radius:20%] *:col-start-1 *:row-start-1',
 | 
				
			||||||
 | 
					        'outline -outline-offset-1 outline-black/10 dark:outline-white/10',
 | 
				
			||||||
 | 
					        // Border radius
 | 
				
			||||||
 | 
					        square ? 'rounded-(--avatar-radius) *:rounded-(--avatar-radius)' : 'rounded-full *:rounded-full'
 | 
				
			||||||
 | 
					      )}
 | 
				
			||||||
 | 
					    >
 | 
				
			||||||
 | 
					      {initials && (
 | 
				
			||||||
 | 
					        <svg
 | 
				
			||||||
 | 
					          className="size-full fill-current p-[5%] text-[48px] font-medium uppercase select-none"
 | 
				
			||||||
 | 
					          viewBox="0 0 100 100"
 | 
				
			||||||
 | 
					          aria-hidden={alt ? undefined : 'true'}
 | 
				
			||||||
 | 
					        >
 | 
				
			||||||
 | 
					          {alt && <title>{alt}</title>}
 | 
				
			||||||
 | 
					          <text x="50%" y="50%" alignmentBaseline="middle" dominantBaseline="middle" textAnchor="middle" dy=".125em">
 | 
				
			||||||
 | 
					            {initials}
 | 
				
			||||||
 | 
					          </text>
 | 
				
			||||||
 | 
					        </svg>
 | 
				
			||||||
 | 
					      )}
 | 
				
			||||||
 | 
					      {src && <img className="size-full" src={src} alt={alt} />}
 | 
				
			||||||
 | 
					    </span>
 | 
				
			||||||
 | 
					  )
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const AvatarButton = forwardRef(function AvatarButton(
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
 | 
					    src,
 | 
				
			||||||
 | 
					    square = false,
 | 
				
			||||||
 | 
					    initials,
 | 
				
			||||||
 | 
					    alt,
 | 
				
			||||||
 | 
					    className,
 | 
				
			||||||
 | 
					    ...props
 | 
				
			||||||
 | 
					  }: AvatarProps &
 | 
				
			||||||
 | 
					    (Omit<Headless.ButtonProps, 'as' | 'className'> | Omit<React.ComponentPropsWithoutRef<typeof Link>, 'className'>),
 | 
				
			||||||
 | 
					  ref: React.ForwardedRef<HTMLElement>
 | 
				
			||||||
 | 
					) {
 | 
				
			||||||
 | 
					  let classes = clsx(
 | 
				
			||||||
 | 
					    className,
 | 
				
			||||||
 | 
					    square ? 'rounded-[20%]' : 'rounded-full',
 | 
				
			||||||
 | 
					    'relative inline-grid focus:outline-hidden data-focus:outline-2 data-focus:outline-offset-2 data-focus:outline-blue-500'
 | 
				
			||||||
 | 
					  )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  return 'href' in props ? (
 | 
				
			||||||
 | 
					    <Link {...props} className={classes} ref={ref as React.ForwardedRef<HTMLAnchorElement>}>
 | 
				
			||||||
 | 
					      <TouchTarget>
 | 
				
			||||||
 | 
					        <Avatar src={src} square={square} initials={initials} alt={alt} />
 | 
				
			||||||
 | 
					      </TouchTarget>
 | 
				
			||||||
 | 
					    </Link>
 | 
				
			||||||
 | 
					  ) : (
 | 
				
			||||||
 | 
					    <Headless.Button {...props} className={classes} ref={ref}>
 | 
				
			||||||
 | 
					      <TouchTarget>
 | 
				
			||||||
 | 
					        <Avatar src={src} square={square} initials={initials} alt={alt} />
 | 
				
			||||||
 | 
					      </TouchTarget>
 | 
				
			||||||
 | 
					    </Headless.Button>
 | 
				
			||||||
 | 
					  )
 | 
				
			||||||
 | 
					})
 | 
				
			||||||
							
								
								
									
										82
									
								
								src/components/badge.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										82
									
								
								src/components/badge.tsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,82 @@
 | 
				
			|||||||
 | 
					import * as Headless from '@headlessui/react'
 | 
				
			||||||
 | 
					import clsx from 'clsx'
 | 
				
			||||||
 | 
					import React, { forwardRef } from 'react'
 | 
				
			||||||
 | 
					import { TouchTarget } from './button'
 | 
				
			||||||
 | 
					import { Link } from './link'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const colors = {
 | 
				
			||||||
 | 
					  red: 'bg-red-500/15 text-red-700 group-data-hover:bg-red-500/25 dark:bg-red-500/10 dark:text-red-400 dark:group-data-hover:bg-red-500/20',
 | 
				
			||||||
 | 
					  orange:
 | 
				
			||||||
 | 
					    'bg-orange-500/15 text-orange-700 group-data-hover:bg-orange-500/25 dark:bg-orange-500/10 dark:text-orange-400 dark:group-data-hover:bg-orange-500/20',
 | 
				
			||||||
 | 
					  amber:
 | 
				
			||||||
 | 
					    'bg-amber-400/20 text-amber-700 group-data-hover:bg-amber-400/30 dark:bg-amber-400/10 dark:text-amber-400 dark:group-data-hover:bg-amber-400/15',
 | 
				
			||||||
 | 
					  yellow:
 | 
				
			||||||
 | 
					    'bg-yellow-400/20 text-yellow-700 group-data-hover:bg-yellow-400/30 dark:bg-yellow-400/10 dark:text-yellow-300 dark:group-data-hover:bg-yellow-400/15',
 | 
				
			||||||
 | 
					  lime: 'bg-lime-400/20 text-lime-700 group-data-hover:bg-lime-400/30 dark:bg-lime-400/10 dark:text-lime-300 dark:group-data-hover:bg-lime-400/15',
 | 
				
			||||||
 | 
					  green:
 | 
				
			||||||
 | 
					    'bg-green-500/15 text-green-700 group-data-hover:bg-green-500/25 dark:bg-green-500/10 dark:text-green-400 dark:group-data-hover:bg-green-500/20',
 | 
				
			||||||
 | 
					  emerald:
 | 
				
			||||||
 | 
					    'bg-emerald-500/15 text-emerald-700 group-data-hover:bg-emerald-500/25 dark:bg-emerald-500/10 dark:text-emerald-400 dark:group-data-hover:bg-emerald-500/20',
 | 
				
			||||||
 | 
					  teal: 'bg-teal-500/15 text-teal-700 group-data-hover:bg-teal-500/25 dark:bg-teal-500/10 dark:text-teal-300 dark:group-data-hover:bg-teal-500/20',
 | 
				
			||||||
 | 
					  cyan: 'bg-cyan-400/20 text-cyan-700 group-data-hover:bg-cyan-400/30 dark:bg-cyan-400/10 dark:text-cyan-300 dark:group-data-hover:bg-cyan-400/15',
 | 
				
			||||||
 | 
					  sky: 'bg-sky-500/15 text-sky-700 group-data-hover:bg-sky-500/25 dark:bg-sky-500/10 dark:text-sky-300 dark:group-data-hover:bg-sky-500/20',
 | 
				
			||||||
 | 
					  blue: 'bg-blue-500/15 text-blue-700 group-data-hover:bg-blue-500/25 dark:text-blue-400 dark:group-data-hover:bg-blue-500/25',
 | 
				
			||||||
 | 
					  indigo:
 | 
				
			||||||
 | 
					    'bg-indigo-500/15 text-indigo-700 group-data-hover:bg-indigo-500/25 dark:text-indigo-400 dark:group-data-hover:bg-indigo-500/20',
 | 
				
			||||||
 | 
					  violet:
 | 
				
			||||||
 | 
					    'bg-violet-500/15 text-violet-700 group-data-hover:bg-violet-500/25 dark:text-violet-400 dark:group-data-hover:bg-violet-500/20',
 | 
				
			||||||
 | 
					  purple:
 | 
				
			||||||
 | 
					    'bg-purple-500/15 text-purple-700 group-data-hover:bg-purple-500/25 dark:text-purple-400 dark:group-data-hover:bg-purple-500/20',
 | 
				
			||||||
 | 
					  fuchsia:
 | 
				
			||||||
 | 
					    'bg-fuchsia-400/15 text-fuchsia-700 group-data-hover:bg-fuchsia-400/25 dark:bg-fuchsia-400/10 dark:text-fuchsia-400 dark:group-data-hover:bg-fuchsia-400/20',
 | 
				
			||||||
 | 
					  pink: 'bg-pink-400/15 text-pink-700 group-data-hover:bg-pink-400/25 dark:bg-pink-400/10 dark:text-pink-400 dark:group-data-hover:bg-pink-400/20',
 | 
				
			||||||
 | 
					  rose: 'bg-rose-400/15 text-rose-700 group-data-hover:bg-rose-400/25 dark:bg-rose-400/10 dark:text-rose-400 dark:group-data-hover:bg-rose-400/20',
 | 
				
			||||||
 | 
					  zinc: 'bg-zinc-600/10 text-zinc-700 group-data-hover:bg-zinc-600/20 dark:bg-white/5 dark:text-zinc-400 dark:group-data-hover:bg-white/10',
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					type BadgeProps = { color?: keyof typeof colors }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function Badge({ color = 'zinc', className, ...props }: BadgeProps & React.ComponentPropsWithoutRef<'span'>) {
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <span
 | 
				
			||||||
 | 
					      {...props}
 | 
				
			||||||
 | 
					      className={clsx(
 | 
				
			||||||
 | 
					        className,
 | 
				
			||||||
 | 
					        'inline-flex items-center gap-x-1.5 rounded-md px-1.5 py-0.5 text-sm/5 font-medium sm:text-xs/5 forced-colors:outline',
 | 
				
			||||||
 | 
					        colors[color]
 | 
				
			||||||
 | 
					      )}
 | 
				
			||||||
 | 
					    />
 | 
				
			||||||
 | 
					  )
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const BadgeButton = forwardRef(function BadgeButton(
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
 | 
					    color = 'zinc',
 | 
				
			||||||
 | 
					    className,
 | 
				
			||||||
 | 
					    children,
 | 
				
			||||||
 | 
					    ...props
 | 
				
			||||||
 | 
					  }: BadgeProps & { className?: string; children: React.ReactNode } & (
 | 
				
			||||||
 | 
					      | Omit<Headless.ButtonProps, 'as' | 'className'>
 | 
				
			||||||
 | 
					      | Omit<React.ComponentPropsWithoutRef<typeof Link>, 'className'>
 | 
				
			||||||
 | 
					    ),
 | 
				
			||||||
 | 
					  ref: React.ForwardedRef<HTMLElement>
 | 
				
			||||||
 | 
					) {
 | 
				
			||||||
 | 
					  let classes = clsx(
 | 
				
			||||||
 | 
					    className,
 | 
				
			||||||
 | 
					    'group relative inline-flex rounded-md focus:outline-hidden data-focus:outline-2 data-focus:outline-offset-2 data-focus:outline-blue-500'
 | 
				
			||||||
 | 
					  )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  return 'href' in props ? (
 | 
				
			||||||
 | 
					    <Link {...props} className={classes} ref={ref as React.ForwardedRef<HTMLAnchorElement>}>
 | 
				
			||||||
 | 
					      <TouchTarget>
 | 
				
			||||||
 | 
					        <Badge color={color}>{children}</Badge>
 | 
				
			||||||
 | 
					      </TouchTarget>
 | 
				
			||||||
 | 
					    </Link>
 | 
				
			||||||
 | 
					  ) : (
 | 
				
			||||||
 | 
					    <Headless.Button {...props} className={classes} ref={ref}>
 | 
				
			||||||
 | 
					      <TouchTarget>
 | 
				
			||||||
 | 
					        <Badge color={color}>{children}</Badge>
 | 
				
			||||||
 | 
					      </TouchTarget>
 | 
				
			||||||
 | 
					    </Headless.Button>
 | 
				
			||||||
 | 
					  )
 | 
				
			||||||
 | 
					})
 | 
				
			||||||
							
								
								
									
										204
									
								
								src/components/button.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										204
									
								
								src/components/button.tsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,204 @@
 | 
				
			|||||||
 | 
					import * as Headless from '@headlessui/react'
 | 
				
			||||||
 | 
					import clsx from 'clsx'
 | 
				
			||||||
 | 
					import React, { forwardRef } from 'react'
 | 
				
			||||||
 | 
					import { Link } from './link'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const styles = {
 | 
				
			||||||
 | 
					  base: [
 | 
				
			||||||
 | 
					    // Base
 | 
				
			||||||
 | 
					    'relative isolate inline-flex items-baseline justify-center gap-x-2 rounded-lg border text-base/6 font-semibold',
 | 
				
			||||||
 | 
					    // Sizing
 | 
				
			||||||
 | 
					    'px-[calc(--spacing(3.5)-1px)] py-[calc(--spacing(2.5)-1px)] sm:px-[calc(--spacing(3)-1px)] sm:py-[calc(--spacing(1.5)-1px)] sm:text-sm/6',
 | 
				
			||||||
 | 
					    // Focus
 | 
				
			||||||
 | 
					    'focus:outline-hidden data-focus:outline data-focus:outline-2 data-focus:outline-offset-2 data-focus:outline-blue-500',
 | 
				
			||||||
 | 
					    // Disabled
 | 
				
			||||||
 | 
					    'data-disabled:opacity-50',
 | 
				
			||||||
 | 
					    // Icon
 | 
				
			||||||
 | 
					    '*:data-[slot=icon]:-mx-0.5 *:data-[slot=icon]:my-0.5 *:data-[slot=icon]:size-5 *:data-[slot=icon]:shrink-0 *:data-[slot=icon]:self-center *:data-[slot=icon]:text-(--btn-icon) sm:*:data-[slot=icon]:my-1 sm:*:data-[slot=icon]:size-4 forced-colors:[--btn-icon:ButtonText] forced-colors:data-hover:[--btn-icon:ButtonText]',
 | 
				
			||||||
 | 
					  ],
 | 
				
			||||||
 | 
					  solid: [
 | 
				
			||||||
 | 
					    // Optical border, implemented as the button background to avoid corner artifacts
 | 
				
			||||||
 | 
					    'border-transparent bg-(--btn-border)',
 | 
				
			||||||
 | 
					    // Dark mode: border is rendered on `after` so background is set to button background
 | 
				
			||||||
 | 
					    'dark:bg-(--btn-bg)',
 | 
				
			||||||
 | 
					    // Button background, implemented as foreground layer to stack on top of pseudo-border layer
 | 
				
			||||||
 | 
					    'before:absolute before:inset-0 before:-z-10 before:rounded-[calc(var(--radius-lg)-1px)] before:bg-(--btn-bg)',
 | 
				
			||||||
 | 
					    // Drop shadow, applied to the inset `before` layer so it blends with the border
 | 
				
			||||||
 | 
					    'before:shadow-sm',
 | 
				
			||||||
 | 
					    // Background color is moved to control and shadow is removed in dark mode so hide `before` pseudo
 | 
				
			||||||
 | 
					    'dark:before:hidden',
 | 
				
			||||||
 | 
					    // Dark mode: Subtle white outline is applied using a border
 | 
				
			||||||
 | 
					    'dark:border-white/5',
 | 
				
			||||||
 | 
					    // Shim/overlay, inset to match button foreground and used for hover state + highlight shadow
 | 
				
			||||||
 | 
					    'after:absolute after:inset-0 after:-z-10 after:rounded-[calc(var(--radius-lg)-1px)]',
 | 
				
			||||||
 | 
					    // Inner highlight shadow
 | 
				
			||||||
 | 
					    'after:shadow-[shadow:inset_0_1px_--theme(--color-white/15%)]',
 | 
				
			||||||
 | 
					    // White overlay on hover
 | 
				
			||||||
 | 
					    'data-active:after:bg-(--btn-hover-overlay) data-hover:after:bg-(--btn-hover-overlay)',
 | 
				
			||||||
 | 
					    // Dark mode: `after` layer expands to cover entire button
 | 
				
			||||||
 | 
					    'dark:after:-inset-px dark:after:rounded-lg',
 | 
				
			||||||
 | 
					    // Disabled
 | 
				
			||||||
 | 
					    'data-disabled:before:shadow-none data-disabled:after:shadow-none',
 | 
				
			||||||
 | 
					  ],
 | 
				
			||||||
 | 
					  outline: [
 | 
				
			||||||
 | 
					    // Base
 | 
				
			||||||
 | 
					    'border-zinc-950/10 text-zinc-950 data-active:bg-zinc-950/[2.5%] data-hover:bg-zinc-950/[2.5%]',
 | 
				
			||||||
 | 
					    // Dark mode
 | 
				
			||||||
 | 
					    'dark:border-white/15 dark:text-white dark:[--btn-bg:transparent] dark:data-active:bg-white/5 dark:data-hover:bg-white/5',
 | 
				
			||||||
 | 
					    // Icon
 | 
				
			||||||
 | 
					    '[--btn-icon:var(--color-zinc-500)] data-active:[--btn-icon:var(--color-zinc-700)] data-hover:[--btn-icon:var(--color-zinc-700)] dark:data-active:[--btn-icon:var(--color-zinc-400)] dark:data-hover:[--btn-icon:var(--color-zinc-400)]',
 | 
				
			||||||
 | 
					  ],
 | 
				
			||||||
 | 
					  plain: [
 | 
				
			||||||
 | 
					    // Base
 | 
				
			||||||
 | 
					    'border-transparent text-zinc-950 data-active:bg-zinc-950/5 data-hover:bg-zinc-950/5',
 | 
				
			||||||
 | 
					    // Dark mode
 | 
				
			||||||
 | 
					    'dark:text-white dark:data-active:bg-white/10 dark:data-hover:bg-white/10',
 | 
				
			||||||
 | 
					    // Icon
 | 
				
			||||||
 | 
					    '[--btn-icon:var(--color-zinc-500)] data-active:[--btn-icon:var(--color-zinc-700)] data-hover:[--btn-icon:var(--color-zinc-700)] dark:[--btn-icon:var(--color-zinc-500)] dark:data-active:[--btn-icon:var(--color-zinc-400)] dark:data-hover:[--btn-icon:var(--color-zinc-400)]',
 | 
				
			||||||
 | 
					  ],
 | 
				
			||||||
 | 
					  colors: {
 | 
				
			||||||
 | 
					    'dark/zinc': [
 | 
				
			||||||
 | 
					      'text-white [--btn-bg:var(--color-zinc-900)] [--btn-border:var(--color-zinc-950)]/90 [--btn-hover-overlay:var(--color-white)]/10',
 | 
				
			||||||
 | 
					      'dark:text-white dark:[--btn-bg:var(--color-zinc-600)] dark:[--btn-hover-overlay:var(--color-white)]/5',
 | 
				
			||||||
 | 
					      '[--btn-icon:var(--color-zinc-400)] data-active:[--btn-icon:var(--color-zinc-300)] data-hover:[--btn-icon:var(--color-zinc-300)]',
 | 
				
			||||||
 | 
					    ],
 | 
				
			||||||
 | 
					    light: [
 | 
				
			||||||
 | 
					      'text-zinc-950 [--btn-bg:white] [--btn-border:var(--color-zinc-950)]/10 [--btn-hover-overlay:var(--color-zinc-950)]/[2.5%] data-active:[--btn-border:var(--color-zinc-950)]/15 data-hover:[--btn-border:var(--color-zinc-950)]/15',
 | 
				
			||||||
 | 
					      'dark:text-white dark:[--btn-hover-overlay:var(--color-white)]/5 dark:[--btn-bg:var(--color-zinc-800)]',
 | 
				
			||||||
 | 
					      '[--btn-icon:var(--color-zinc-500)] data-active:[--btn-icon:var(--color-zinc-700)] data-hover:[--btn-icon:var(--color-zinc-700)] dark:[--btn-icon:var(--color-zinc-500)] dark:data-active:[--btn-icon:var(--color-zinc-400)] dark:data-hover:[--btn-icon:var(--color-zinc-400)]',
 | 
				
			||||||
 | 
					    ],
 | 
				
			||||||
 | 
					    'dark/white': [
 | 
				
			||||||
 | 
					      'text-white [--btn-bg:var(--color-zinc-900)] [--btn-border:var(--color-zinc-950)]/90 [--btn-hover-overlay:var(--color-white)]/10',
 | 
				
			||||||
 | 
					      'dark:text-zinc-950 dark:[--btn-bg:white] dark:[--btn-hover-overlay:var(--color-zinc-950)]/5',
 | 
				
			||||||
 | 
					      '[--btn-icon:var(--color-zinc-400)] data-active:[--btn-icon:var(--color-zinc-300)] data-hover:[--btn-icon:var(--color-zinc-300)] dark:[--btn-icon:var(--color-zinc-500)] dark:data-active:[--btn-icon:var(--color-zinc-400)] dark:data-hover:[--btn-icon:var(--color-zinc-400)]',
 | 
				
			||||||
 | 
					    ],
 | 
				
			||||||
 | 
					    dark: [
 | 
				
			||||||
 | 
					      'text-white [--btn-bg:var(--color-zinc-900)] [--btn-border:var(--color-zinc-950)]/90 [--btn-hover-overlay:var(--color-white)]/10',
 | 
				
			||||||
 | 
					      'dark:[--btn-hover-overlay:var(--color-white)]/5 dark:[--btn-bg:var(--color-zinc-800)]',
 | 
				
			||||||
 | 
					      '[--btn-icon:var(--color-zinc-400)] data-active:[--btn-icon:var(--color-zinc-300)] data-hover:[--btn-icon:var(--color-zinc-300)]',
 | 
				
			||||||
 | 
					    ],
 | 
				
			||||||
 | 
					    white: [
 | 
				
			||||||
 | 
					      'text-zinc-950 [--btn-bg:white] [--btn-border:var(--color-zinc-950)]/10 [--btn-hover-overlay:var(--color-zinc-950)]/[2.5%] data-active:[--btn-border:var(--color-zinc-950)]/15 data-hover:[--btn-border:var(--color-zinc-950)]/15',
 | 
				
			||||||
 | 
					      'dark:[--btn-hover-overlay:var(--color-zinc-950)]/5',
 | 
				
			||||||
 | 
					      '[--btn-icon:var(--color-zinc-400)] data-active:[--btn-icon:var(--color-zinc-500)] data-hover:[--btn-icon:var(--color-zinc-500)]',
 | 
				
			||||||
 | 
					    ],
 | 
				
			||||||
 | 
					    zinc: [
 | 
				
			||||||
 | 
					      'text-white [--btn-hover-overlay:var(--color-white)]/10 [--btn-bg:var(--color-zinc-600)] [--btn-border:var(--color-zinc-700)]/90',
 | 
				
			||||||
 | 
					      'dark:[--btn-hover-overlay:var(--color-white)]/5',
 | 
				
			||||||
 | 
					      '[--btn-icon:var(--color-zinc-400)] data-active:[--btn-icon:var(--color-zinc-300)] data-hover:[--btn-icon:var(--color-zinc-300)]',
 | 
				
			||||||
 | 
					    ],
 | 
				
			||||||
 | 
					    indigo: [
 | 
				
			||||||
 | 
					      'text-white [--btn-hover-overlay:var(--color-white)]/10 [--btn-bg:var(--color-indigo-500)] [--btn-border:var(--color-indigo-600)]/90',
 | 
				
			||||||
 | 
					      '[--btn-icon:var(--color-indigo-300)] data-active:[--btn-icon:var(--color-indigo-200)] data-hover:[--btn-icon:var(--color-indigo-200)]',
 | 
				
			||||||
 | 
					    ],
 | 
				
			||||||
 | 
					    cyan: [
 | 
				
			||||||
 | 
					      'text-cyan-950 [--btn-bg:var(--color-cyan-300)] [--btn-border:var(--color-cyan-400)]/80 [--btn-hover-overlay:var(--color-white)]/25',
 | 
				
			||||||
 | 
					      '[--btn-icon:var(--color-cyan-500)]',
 | 
				
			||||||
 | 
					    ],
 | 
				
			||||||
 | 
					    red: [
 | 
				
			||||||
 | 
					      'text-white [--btn-hover-overlay:var(--color-white)]/10 [--btn-bg:var(--color-red-600)] [--btn-border:var(--color-red-700)]/90',
 | 
				
			||||||
 | 
					      '[--btn-icon:var(--color-red-300)] data-active:[--btn-icon:var(--color-red-200)] data-hover:[--btn-icon:var(--color-red-200)]',
 | 
				
			||||||
 | 
					    ],
 | 
				
			||||||
 | 
					    orange: [
 | 
				
			||||||
 | 
					      'text-white [--btn-hover-overlay:var(--color-white)]/10 [--btn-bg:var(--color-orange-500)] [--btn-border:var(--color-orange-600)]/90',
 | 
				
			||||||
 | 
					      '[--btn-icon:var(--color-orange-300)] data-active:[--btn-icon:var(--color-orange-200)] data-hover:[--btn-icon:var(--color-orange-200)]',
 | 
				
			||||||
 | 
					    ],
 | 
				
			||||||
 | 
					    amber: [
 | 
				
			||||||
 | 
					      'text-amber-950 [--btn-hover-overlay:var(--color-white)]/25 [--btn-bg:var(--color-amber-400)] [--btn-border:var(--color-amber-500)]/80',
 | 
				
			||||||
 | 
					      '[--btn-icon:var(--color-amber-600)]',
 | 
				
			||||||
 | 
					    ],
 | 
				
			||||||
 | 
					    yellow: [
 | 
				
			||||||
 | 
					      'text-yellow-950 [--btn-hover-overlay:var(--color-white)]/25 [--btn-bg:var(--color-yellow-300)] [--btn-border:var(--color-yellow-400)]/80',
 | 
				
			||||||
 | 
					      '[--btn-icon:var(--color-yellow-600)] data-active:[--btn-icon:var(--color-yellow-700)] data-hover:[--btn-icon:var(--color-yellow-700)]',
 | 
				
			||||||
 | 
					    ],
 | 
				
			||||||
 | 
					    lime: [
 | 
				
			||||||
 | 
					      'text-lime-950 [--btn-hover-overlay:var(--color-white)]/25 [--btn-bg:var(--color-lime-300)] [--btn-border:var(--color-lime-400)]/80',
 | 
				
			||||||
 | 
					      '[--btn-icon:var(--color-lime-600)] data-active:[--btn-icon:var(--color-lime-700)] data-hover:[--btn-icon:var(--color-lime-700)]',
 | 
				
			||||||
 | 
					    ],
 | 
				
			||||||
 | 
					    green: [
 | 
				
			||||||
 | 
					      'text-white [--btn-hover-overlay:var(--color-white)]/10 [--btn-bg:var(--color-green-600)] [--btn-border:var(--color-green-700)]/90',
 | 
				
			||||||
 | 
					      '[--btn-icon:var(--color-white)]/60 data-active:[--btn-icon:var(--color-white)]/80 data-hover:[--btn-icon:var(--color-white)]/80',
 | 
				
			||||||
 | 
					    ],
 | 
				
			||||||
 | 
					    emerald: [
 | 
				
			||||||
 | 
					      'text-white [--btn-hover-overlay:var(--color-white)]/10 [--btn-bg:var(--color-emerald-600)] [--btn-border:var(--color-emerald-700)]/90',
 | 
				
			||||||
 | 
					      '[--btn-icon:var(--color-white)]/60 data-active:[--btn-icon:var(--color-white)]/80 data-hover:[--btn-icon:var(--color-white)]/80',
 | 
				
			||||||
 | 
					    ],
 | 
				
			||||||
 | 
					    teal: [
 | 
				
			||||||
 | 
					      'text-white [--btn-hover-overlay:var(--color-white)]/10 [--btn-bg:var(--color-teal-600)] [--btn-border:var(--color-teal-700)]/90',
 | 
				
			||||||
 | 
					      '[--btn-icon:var(--color-white)]/60 data-active:[--btn-icon:var(--color-white)]/80 data-hover:[--btn-icon:var(--color-white)]/80',
 | 
				
			||||||
 | 
					    ],
 | 
				
			||||||
 | 
					    sky: [
 | 
				
			||||||
 | 
					      'text-white [--btn-hover-overlay:var(--color-white)]/10 [--btn-bg:var(--color-sky-500)] [--btn-border:var(--color-sky-600)]/80',
 | 
				
			||||||
 | 
					      '[--btn-icon:var(--color-white)]/60 data-active:[--btn-icon:var(--color-white)]/80 data-hover:[--btn-icon:var(--color-white)]/80',
 | 
				
			||||||
 | 
					    ],
 | 
				
			||||||
 | 
					    blue: [
 | 
				
			||||||
 | 
					      'text-white [--btn-hover-overlay:var(--color-white)]/10 [--btn-bg:var(--color-blue-600)] [--btn-border:var(--color-blue-700)]/90',
 | 
				
			||||||
 | 
					      '[--btn-icon:var(--color-blue-400)] data-active:[--btn-icon:var(--color-blue-300)] data-hover:[--btn-icon:var(--color-blue-300)]',
 | 
				
			||||||
 | 
					    ],
 | 
				
			||||||
 | 
					    violet: [
 | 
				
			||||||
 | 
					      'text-white [--btn-hover-overlay:var(--color-white)]/10 [--btn-bg:var(--color-violet-500)] [--btn-border:var(--color-violet-600)]/90',
 | 
				
			||||||
 | 
					      '[--btn-icon:var(--color-violet-300)] data-active:[--btn-icon:var(--color-violet-200)] data-hover:[--btn-icon:var(--color-violet-200)]',
 | 
				
			||||||
 | 
					    ],
 | 
				
			||||||
 | 
					    purple: [
 | 
				
			||||||
 | 
					      'text-white [--btn-hover-overlay:var(--color-white)]/10 [--btn-bg:var(--color-purple-500)] [--btn-border:var(--color-purple-600)]/90',
 | 
				
			||||||
 | 
					      '[--btn-icon:var(--color-purple-300)] data-active:[--btn-icon:var(--color-purple-200)] data-hover:[--btn-icon:var(--color-purple-200)]',
 | 
				
			||||||
 | 
					    ],
 | 
				
			||||||
 | 
					    fuchsia: [
 | 
				
			||||||
 | 
					      'text-white [--btn-hover-overlay:var(--color-white)]/10 [--btn-bg:var(--color-fuchsia-500)] [--btn-border:var(--color-fuchsia-600)]/90',
 | 
				
			||||||
 | 
					      '[--btn-icon:var(--color-fuchsia-300)] data-active:[--btn-icon:var(--color-fuchsia-200)] data-hover:[--btn-icon:var(--color-fuchsia-200)]',
 | 
				
			||||||
 | 
					    ],
 | 
				
			||||||
 | 
					    pink: [
 | 
				
			||||||
 | 
					      'text-white [--btn-hover-overlay:var(--color-white)]/10 [--btn-bg:var(--color-pink-500)] [--btn-border:var(--color-pink-600)]/90',
 | 
				
			||||||
 | 
					      '[--btn-icon:var(--color-pink-300)] data-active:[--btn-icon:var(--color-pink-200)] data-hover:[--btn-icon:var(--color-pink-200)]',
 | 
				
			||||||
 | 
					    ],
 | 
				
			||||||
 | 
					    rose: [
 | 
				
			||||||
 | 
					      'text-white [--btn-hover-overlay:var(--color-white)]/10 [--btn-bg:var(--color-rose-500)] [--btn-border:var(--color-rose-600)]/90',
 | 
				
			||||||
 | 
					      '[--btn-icon:var(--color-rose-300)] data-active:[--btn-icon:var(--color-rose-200)] data-hover:[--btn-icon:var(--color-rose-200)]',
 | 
				
			||||||
 | 
					    ],
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					type ButtonProps = (
 | 
				
			||||||
 | 
					  | { color?: keyof typeof styles.colors; outline?: never; plain?: never }
 | 
				
			||||||
 | 
					  | { color?: never; outline: true; plain?: never }
 | 
				
			||||||
 | 
					  | { color?: never; outline?: never; plain: true }
 | 
				
			||||||
 | 
					) & { className?: string; children: React.ReactNode } & (
 | 
				
			||||||
 | 
					    | Omit<Headless.ButtonProps, 'as' | 'className'>
 | 
				
			||||||
 | 
					    | Omit<React.ComponentPropsWithoutRef<typeof Link>, 'className'>
 | 
				
			||||||
 | 
					  )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const Button = forwardRef(function Button(
 | 
				
			||||||
 | 
					  { color, outline, plain, className, children, ...props }: ButtonProps,
 | 
				
			||||||
 | 
					  ref: React.ForwardedRef<HTMLElement>
 | 
				
			||||||
 | 
					) {
 | 
				
			||||||
 | 
					  let classes = clsx(
 | 
				
			||||||
 | 
					    className,
 | 
				
			||||||
 | 
					    styles.base,
 | 
				
			||||||
 | 
					    outline ? styles.outline : plain ? styles.plain : clsx(styles.solid, styles.colors[color ?? 'dark/zinc'])
 | 
				
			||||||
 | 
					  )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  return 'href' in props ? (
 | 
				
			||||||
 | 
					    <Link {...props} className={classes} ref={ref as React.ForwardedRef<HTMLAnchorElement>}>
 | 
				
			||||||
 | 
					      <TouchTarget>{children}</TouchTarget>
 | 
				
			||||||
 | 
					    </Link>
 | 
				
			||||||
 | 
					  ) : (
 | 
				
			||||||
 | 
					    <Headless.Button {...props} className={clsx(classes, 'cursor-default')} ref={ref}>
 | 
				
			||||||
 | 
					      <TouchTarget>{children}</TouchTarget>
 | 
				
			||||||
 | 
					    </Headless.Button>
 | 
				
			||||||
 | 
					  )
 | 
				
			||||||
 | 
					})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/**
 | 
				
			||||||
 | 
					 * Expand the hit area to at least 44×44px on touch devices
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
 | 
					export function TouchTarget({ children }: { children: React.ReactNode }) {
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <>
 | 
				
			||||||
 | 
					      <span
 | 
				
			||||||
 | 
					        className="absolute top-1/2 left-1/2 size-[max(100%,2.75rem)] -translate-x-1/2 -translate-y-1/2 [@media(pointer:fine)]:hidden"
 | 
				
			||||||
 | 
					        aria-hidden="true"
 | 
				
			||||||
 | 
					      />
 | 
				
			||||||
 | 
					      {children}
 | 
				
			||||||
 | 
					    </>
 | 
				
			||||||
 | 
					  )
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										157
									
								
								src/components/checkbox.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										157
									
								
								src/components/checkbox.tsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,157 @@
 | 
				
			|||||||
 | 
					import * as Headless from '@headlessui/react'
 | 
				
			||||||
 | 
					import clsx from 'clsx'
 | 
				
			||||||
 | 
					import type React from 'react'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function CheckboxGroup({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) {
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <div
 | 
				
			||||||
 | 
					      data-slot="control"
 | 
				
			||||||
 | 
					      {...props}
 | 
				
			||||||
 | 
					      className={clsx(
 | 
				
			||||||
 | 
					        className,
 | 
				
			||||||
 | 
					        // Basic groups
 | 
				
			||||||
 | 
					        'space-y-3',
 | 
				
			||||||
 | 
					        // With descriptions
 | 
				
			||||||
 | 
					        'has-data-[slot=description]:space-y-6 has-data-[slot=description]:**:data-[slot=label]:font-medium'
 | 
				
			||||||
 | 
					      )}
 | 
				
			||||||
 | 
					    />
 | 
				
			||||||
 | 
					  )
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function CheckboxField({
 | 
				
			||||||
 | 
					  className,
 | 
				
			||||||
 | 
					  ...props
 | 
				
			||||||
 | 
					}: { className?: string } & Omit<Headless.FieldProps, 'as' | 'className'>) {
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <Headless.Field
 | 
				
			||||||
 | 
					      data-slot="field"
 | 
				
			||||||
 | 
					      {...props}
 | 
				
			||||||
 | 
					      className={clsx(
 | 
				
			||||||
 | 
					        className,
 | 
				
			||||||
 | 
					        // Base layout
 | 
				
			||||||
 | 
					        'grid grid-cols-[1.125rem_1fr] gap-x-4 gap-y-1 sm:grid-cols-[1rem_1fr]',
 | 
				
			||||||
 | 
					        // Control layout
 | 
				
			||||||
 | 
					        '*:data-[slot=control]:col-start-1 *:data-[slot=control]:row-start-1 *:data-[slot=control]:mt-0.75 sm:*:data-[slot=control]:mt-1',
 | 
				
			||||||
 | 
					        // Label layout
 | 
				
			||||||
 | 
					        '*:data-[slot=label]:col-start-2 *:data-[slot=label]:row-start-1',
 | 
				
			||||||
 | 
					        // Description layout
 | 
				
			||||||
 | 
					        '*:data-[slot=description]:col-start-2 *:data-[slot=description]:row-start-2',
 | 
				
			||||||
 | 
					        // With description
 | 
				
			||||||
 | 
					        'has-data-[slot=description]:**:data-[slot=label]:font-medium'
 | 
				
			||||||
 | 
					      )}
 | 
				
			||||||
 | 
					    />
 | 
				
			||||||
 | 
					  )
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const base = [
 | 
				
			||||||
 | 
					  // Basic layout
 | 
				
			||||||
 | 
					  'relative isolate flex size-[1.125rem] items-center justify-center rounded-[0.3125rem] sm:size-4',
 | 
				
			||||||
 | 
					  // Background color + shadow applied to inset pseudo element, so shadow blends with border in light mode
 | 
				
			||||||
 | 
					  'before:absolute before:inset-0 before:-z-10 before:rounded-[calc(0.3125rem-1px)] before:bg-white before:shadow-sm',
 | 
				
			||||||
 | 
					  // Background color when checked
 | 
				
			||||||
 | 
					  'group-data-checked:before:bg-(--checkbox-checked-bg)',
 | 
				
			||||||
 | 
					  // Background color is moved to control and shadow is removed in dark mode so hide `before` pseudo
 | 
				
			||||||
 | 
					  'dark:before:hidden',
 | 
				
			||||||
 | 
					  // Background color applied to control in dark mode
 | 
				
			||||||
 | 
					  'dark:bg-white/5 dark:group-data-checked:bg-(--checkbox-checked-bg)',
 | 
				
			||||||
 | 
					  // Border
 | 
				
			||||||
 | 
					  'border border-zinc-950/15 group-data-checked:border-transparent group-data-hover:group-data-checked:border-transparent group-data-hover:border-zinc-950/30 group-data-checked:bg-(--checkbox-checked-border)',
 | 
				
			||||||
 | 
					  'dark:border-white/15 dark:group-data-checked:border-white/5 dark:group-data-hover:group-data-checked:border-white/5 dark:group-data-hover:border-white/30',
 | 
				
			||||||
 | 
					  // Inner highlight shadow
 | 
				
			||||||
 | 
					  'after:absolute after:inset-0 after:rounded-[calc(0.3125rem-1px)] after:shadow-[inset_0_1px_--theme(--color-white/15%)]',
 | 
				
			||||||
 | 
					  'dark:after:-inset-px dark:after:hidden dark:after:rounded-[0.3125rem] dark:group-data-checked:after:block',
 | 
				
			||||||
 | 
					  // Focus ring
 | 
				
			||||||
 | 
					  'group-data-focus:outline group-data-focus:outline-2 group-data-focus:outline-offset-2 group-data-focus:outline-blue-500',
 | 
				
			||||||
 | 
					  // Disabled state
 | 
				
			||||||
 | 
					  'group-data-disabled:opacity-50',
 | 
				
			||||||
 | 
					  'group-data-disabled:border-zinc-950/25 group-data-disabled:bg-zinc-950/5 group-data-disabled:[--checkbox-check:var(--color-zinc-950)]/50 group-data-disabled:before:bg-transparent',
 | 
				
			||||||
 | 
					  'dark:group-data-disabled:border-white/20 dark:group-data-disabled:bg-white/[2.5%] dark:group-data-disabled:[--checkbox-check:var(--color-white)]/50 dark:group-data-checked:group-data-disabled:after:hidden',
 | 
				
			||||||
 | 
					  // Forced colors mode
 | 
				
			||||||
 | 
					  'forced-colors:[--checkbox-check:HighlightText] forced-colors:[--checkbox-checked-bg:Highlight] forced-colors:group-data-disabled:[--checkbox-check:Highlight]',
 | 
				
			||||||
 | 
					  'dark:forced-colors:[--checkbox-check:HighlightText] dark:forced-colors:[--checkbox-checked-bg:Highlight] dark:forced-colors:group-data-disabled:[--checkbox-check:Highlight]',
 | 
				
			||||||
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const colors = {
 | 
				
			||||||
 | 
					  'dark/zinc': [
 | 
				
			||||||
 | 
					    '[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-zinc-900)] [--checkbox-checked-border:var(--color-zinc-950)]/90',
 | 
				
			||||||
 | 
					    'dark:[--checkbox-checked-bg:var(--color-zinc-600)]',
 | 
				
			||||||
 | 
					  ],
 | 
				
			||||||
 | 
					  'dark/white': [
 | 
				
			||||||
 | 
					    '[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-zinc-900)] [--checkbox-checked-border:var(--color-zinc-950)]/90',
 | 
				
			||||||
 | 
					    'dark:[--checkbox-check:var(--color-zinc-900)] dark:[--checkbox-checked-bg:var(--color-white)] dark:[--checkbox-checked-border:var(--color-zinc-950)]/15',
 | 
				
			||||||
 | 
					  ],
 | 
				
			||||||
 | 
					  white:
 | 
				
			||||||
 | 
					    '[--checkbox-check:var(--color-zinc-900)] [--checkbox-checked-bg:var(--color-white)] [--checkbox-checked-border:var(--color-zinc-950)]/15',
 | 
				
			||||||
 | 
					  dark: '[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-zinc-900)] [--checkbox-checked-border:var(--color-zinc-950)]/90',
 | 
				
			||||||
 | 
					  zinc: '[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-zinc-600)] [--checkbox-checked-border:var(--color-zinc-700)]/90',
 | 
				
			||||||
 | 
					  red: '[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-red-600)] [--checkbox-checked-border:var(--color-red-700)]/90',
 | 
				
			||||||
 | 
					  orange:
 | 
				
			||||||
 | 
					    '[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-orange-500)] [--checkbox-checked-border:var(--color-orange-600)]/90',
 | 
				
			||||||
 | 
					  amber:
 | 
				
			||||||
 | 
					    '[--checkbox-check:var(--color-amber-950)] [--checkbox-checked-bg:var(--color-amber-400)] [--checkbox-checked-border:var(--color-amber-500)]/80',
 | 
				
			||||||
 | 
					  yellow:
 | 
				
			||||||
 | 
					    '[--checkbox-check:var(--color-yellow-950)] [--checkbox-checked-bg:var(--color-yellow-300)] [--checkbox-checked-border:var(--color-yellow-400)]/80',
 | 
				
			||||||
 | 
					  lime: '[--checkbox-check:var(--color-lime-950)] [--checkbox-checked-bg:var(--color-lime-300)] [--checkbox-checked-border:var(--color-lime-400)]/80',
 | 
				
			||||||
 | 
					  green:
 | 
				
			||||||
 | 
					    '[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-green-600)] [--checkbox-checked-border:var(--color-green-700)]/90',
 | 
				
			||||||
 | 
					  emerald:
 | 
				
			||||||
 | 
					    '[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-emerald-600)] [--checkbox-checked-border:var(--color-emerald-700)]/90',
 | 
				
			||||||
 | 
					  teal: '[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-teal-600)] [--checkbox-checked-border:var(--color-teal-700)]/90',
 | 
				
			||||||
 | 
					  cyan: '[--checkbox-check:var(--color-cyan-950)] [--checkbox-checked-bg:var(--color-cyan-300)] [--checkbox-checked-border:var(--color-cyan-400)]/80',
 | 
				
			||||||
 | 
					  sky: '[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-sky-500)] [--checkbox-checked-border:var(--color-sky-600)]/80',
 | 
				
			||||||
 | 
					  blue: '[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-blue-600)] [--checkbox-checked-border:var(--color-blue-700)]/90',
 | 
				
			||||||
 | 
					  indigo:
 | 
				
			||||||
 | 
					    '[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-indigo-500)] [--checkbox-checked-border:var(--color-indigo-600)]/90',
 | 
				
			||||||
 | 
					  violet:
 | 
				
			||||||
 | 
					    '[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-violet-500)] [--checkbox-checked-border:var(--color-violet-600)]/90',
 | 
				
			||||||
 | 
					  purple:
 | 
				
			||||||
 | 
					    '[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-purple-500)] [--checkbox-checked-border:var(--color-purple-600)]/90',
 | 
				
			||||||
 | 
					  fuchsia:
 | 
				
			||||||
 | 
					    '[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-fuchsia-500)] [--checkbox-checked-border:var(--color-fuchsia-600)]/90',
 | 
				
			||||||
 | 
					  pink: '[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-pink-500)] [--checkbox-checked-border:var(--color-pink-600)]/90',
 | 
				
			||||||
 | 
					  rose: '[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-rose-500)] [--checkbox-checked-border:var(--color-rose-600)]/90',
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					type Color = keyof typeof colors
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function Checkbox({
 | 
				
			||||||
 | 
					  color = 'dark/zinc',
 | 
				
			||||||
 | 
					  className,
 | 
				
			||||||
 | 
					  ...props
 | 
				
			||||||
 | 
					}: {
 | 
				
			||||||
 | 
					  color?: Color
 | 
				
			||||||
 | 
					  className?: string
 | 
				
			||||||
 | 
					} & Omit<Headless.CheckboxProps, 'as' | 'className'>) {
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <Headless.Checkbox
 | 
				
			||||||
 | 
					      data-slot="control"
 | 
				
			||||||
 | 
					      {...props}
 | 
				
			||||||
 | 
					      className={clsx(className, 'group inline-flex focus:outline-hidden')}
 | 
				
			||||||
 | 
					    >
 | 
				
			||||||
 | 
					      <span className={clsx([base, colors[color]])}>
 | 
				
			||||||
 | 
					        <svg
 | 
				
			||||||
 | 
					          className="size-4 stroke-(--checkbox-check) opacity-0 group-data-checked:opacity-100 sm:h-3.5 sm:w-3.5"
 | 
				
			||||||
 | 
					          viewBox="0 0 14 14"
 | 
				
			||||||
 | 
					          fill="none"
 | 
				
			||||||
 | 
					        >
 | 
				
			||||||
 | 
					          {/* Checkmark icon */}
 | 
				
			||||||
 | 
					          <path
 | 
				
			||||||
 | 
					            className="opacity-100 group-data-indeterminate:opacity-0"
 | 
				
			||||||
 | 
					            d="M3 8L6 11L11 3.5"
 | 
				
			||||||
 | 
					            strokeWidth={2}
 | 
				
			||||||
 | 
					            strokeLinecap="round"
 | 
				
			||||||
 | 
					            strokeLinejoin="round"
 | 
				
			||||||
 | 
					          />
 | 
				
			||||||
 | 
					          {/* Indeterminate icon */}
 | 
				
			||||||
 | 
					          <path
 | 
				
			||||||
 | 
					            className="opacity-0 group-data-indeterminate:opacity-100"
 | 
				
			||||||
 | 
					            d="M3 7H11"
 | 
				
			||||||
 | 
					            strokeWidth={2}
 | 
				
			||||||
 | 
					            strokeLinecap="round"
 | 
				
			||||||
 | 
					            strokeLinejoin="round"
 | 
				
			||||||
 | 
					          />
 | 
				
			||||||
 | 
					        </svg>
 | 
				
			||||||
 | 
					      </span>
 | 
				
			||||||
 | 
					    </Headless.Checkbox>
 | 
				
			||||||
 | 
					  )
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										188
									
								
								src/components/combobox.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										188
									
								
								src/components/combobox.tsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,188 @@
 | 
				
			|||||||
 | 
					'use client'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import * as Headless from '@headlessui/react'
 | 
				
			||||||
 | 
					import clsx from 'clsx'
 | 
				
			||||||
 | 
					import { useState } from 'react'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function Combobox<T>({
 | 
				
			||||||
 | 
					  options,
 | 
				
			||||||
 | 
					  displayValue,
 | 
				
			||||||
 | 
					  filter,
 | 
				
			||||||
 | 
					  anchor = 'bottom',
 | 
				
			||||||
 | 
					  className,
 | 
				
			||||||
 | 
					  placeholder,
 | 
				
			||||||
 | 
					  autoFocus,
 | 
				
			||||||
 | 
					  'aria-label': ariaLabel,
 | 
				
			||||||
 | 
					  children,
 | 
				
			||||||
 | 
					  ...props
 | 
				
			||||||
 | 
					}: {
 | 
				
			||||||
 | 
					  options: T[]
 | 
				
			||||||
 | 
					  displayValue: (value: T | null) => string | undefined
 | 
				
			||||||
 | 
					  filter?: (value: T, query: string) => boolean
 | 
				
			||||||
 | 
					  className?: string
 | 
				
			||||||
 | 
					  placeholder?: string
 | 
				
			||||||
 | 
					  autoFocus?: boolean
 | 
				
			||||||
 | 
					  'aria-label'?: string
 | 
				
			||||||
 | 
					  children: (value: NonNullable<T>) => React.ReactElement
 | 
				
			||||||
 | 
					} & Omit<Headless.ComboboxProps<T, false>, 'as' | 'multiple' | 'children'> & { anchor?: 'top' | 'bottom' }) {
 | 
				
			||||||
 | 
					  const [query, setQuery] = useState('')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const filteredOptions =
 | 
				
			||||||
 | 
					    query === ''
 | 
				
			||||||
 | 
					      ? options
 | 
				
			||||||
 | 
					      : options.filter((option) =>
 | 
				
			||||||
 | 
					          filter ? filter(option, query) : displayValue(option)?.toLowerCase().includes(query.toLowerCase())
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <Headless.Combobox {...props} multiple={false} virtual={{ options: filteredOptions }} onClose={() => setQuery('')}>
 | 
				
			||||||
 | 
					      <span
 | 
				
			||||||
 | 
					        data-slot="control"
 | 
				
			||||||
 | 
					        className={clsx([
 | 
				
			||||||
 | 
					          className,
 | 
				
			||||||
 | 
					          // Basic layout
 | 
				
			||||||
 | 
					          'relative block w-full',
 | 
				
			||||||
 | 
					          // Background color + shadow applied to inset pseudo element, so shadow blends with border in light mode
 | 
				
			||||||
 | 
					          'before:absolute before:inset-px before:rounded-[calc(var(--radius-lg)-1px)] before:bg-white before:shadow-sm',
 | 
				
			||||||
 | 
					          // Background color is moved to control and shadow is removed in dark mode so hide `before` pseudo
 | 
				
			||||||
 | 
					          'dark:before:hidden',
 | 
				
			||||||
 | 
					          // Focus ring
 | 
				
			||||||
 | 
					          'after:pointer-events-none after:absolute after:inset-0 after:rounded-lg after:ring-transparent after:ring-inset sm:focus-within:after:ring-2 sm:focus-within:after:ring-blue-500',
 | 
				
			||||||
 | 
					          // Disabled state
 | 
				
			||||||
 | 
					          'has-data-disabled:opacity-50 has-data-disabled:before:bg-zinc-950/5 has-data-disabled:before:shadow-none',
 | 
				
			||||||
 | 
					          // Invalid state
 | 
				
			||||||
 | 
					          'has-data-invalid:before:shadow-red-500/10',
 | 
				
			||||||
 | 
					        ])}
 | 
				
			||||||
 | 
					      >
 | 
				
			||||||
 | 
					        <Headless.ComboboxInput
 | 
				
			||||||
 | 
					          autoFocus={autoFocus}
 | 
				
			||||||
 | 
					          data-slot="control"
 | 
				
			||||||
 | 
					          aria-label={ariaLabel}
 | 
				
			||||||
 | 
					          displayValue={(option: T) => displayValue(option) ?? ''}
 | 
				
			||||||
 | 
					          onChange={(event) => setQuery(event.target.value)}
 | 
				
			||||||
 | 
					          placeholder={placeholder}
 | 
				
			||||||
 | 
					          className={clsx([
 | 
				
			||||||
 | 
					            className,
 | 
				
			||||||
 | 
					            // Basic layout
 | 
				
			||||||
 | 
					            'relative block w-full appearance-none rounded-lg py-[calc(--spacing(2.5)-1px)] sm:py-[calc(--spacing(1.5)-1px)]',
 | 
				
			||||||
 | 
					            // Horizontal padding
 | 
				
			||||||
 | 
					            'pr-[calc(--spacing(10)-1px)] pl-[calc(--spacing(3.5)-1px)] sm:pr-[calc(--spacing(9)-1px)] sm:pl-[calc(--spacing(3)-1px)]',
 | 
				
			||||||
 | 
					            // Typography
 | 
				
			||||||
 | 
					            'text-base/6 text-zinc-950 placeholder:text-zinc-500 sm:text-sm/6 dark:text-white',
 | 
				
			||||||
 | 
					            // Border
 | 
				
			||||||
 | 
					            'border border-zinc-950/10 data-hover:border-zinc-950/20 dark:border-white/10 dark:data-hover:border-white/20',
 | 
				
			||||||
 | 
					            // Background color
 | 
				
			||||||
 | 
					            'bg-transparent dark:bg-white/5',
 | 
				
			||||||
 | 
					            // Hide default focus styles
 | 
				
			||||||
 | 
					            'focus:outline-hidden',
 | 
				
			||||||
 | 
					            // Invalid state
 | 
				
			||||||
 | 
					            'data-invalid:border-red-500 data-invalid:data-hover:border-red-500 dark:data-invalid:border-red-500 dark:data-invalid:data-hover:border-red-500',
 | 
				
			||||||
 | 
					            // Disabled state
 | 
				
			||||||
 | 
					            'data-disabled:border-zinc-950/20 dark:data-disabled:border-white/15 dark:data-disabled:bg-white/[2.5%] dark:data-hover:data-disabled:border-white/15',
 | 
				
			||||||
 | 
					            // System icons
 | 
				
			||||||
 | 
					            'dark:[color-scheme:dark]',
 | 
				
			||||||
 | 
					          ])}
 | 
				
			||||||
 | 
					        />
 | 
				
			||||||
 | 
					        <Headless.ComboboxButton className="group absolute inset-y-0 right-0 flex items-center px-2">
 | 
				
			||||||
 | 
					          <svg
 | 
				
			||||||
 | 
					            className="size-5 stroke-zinc-500 group-data-disabled:stroke-zinc-600 group-data-hover:stroke-zinc-700 sm:size-4 dark:stroke-zinc-400 dark:group-data-hover:stroke-zinc-300 forced-colors:stroke-[CanvasText]"
 | 
				
			||||||
 | 
					            viewBox="0 0 16 16"
 | 
				
			||||||
 | 
					            aria-hidden="true"
 | 
				
			||||||
 | 
					            fill="none"
 | 
				
			||||||
 | 
					          >
 | 
				
			||||||
 | 
					            <path d="M5.75 10.75L8 13L10.25 10.75" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" />
 | 
				
			||||||
 | 
					            <path d="M10.25 5.25L8 3L5.75 5.25" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" />
 | 
				
			||||||
 | 
					          </svg>
 | 
				
			||||||
 | 
					        </Headless.ComboboxButton>
 | 
				
			||||||
 | 
					      </span>
 | 
				
			||||||
 | 
					      <Headless.ComboboxOptions
 | 
				
			||||||
 | 
					        transition
 | 
				
			||||||
 | 
					        anchor={anchor}
 | 
				
			||||||
 | 
					        className={clsx(
 | 
				
			||||||
 | 
					          // Anchor positioning
 | 
				
			||||||
 | 
					          '[--anchor-gap:--spacing(2)] [--anchor-padding:--spacing(4)] sm:data-[anchor~=start]:[--anchor-offset:-4px]',
 | 
				
			||||||
 | 
					          // Base styles,
 | 
				
			||||||
 | 
					          'isolate min-w-[calc(var(--input-width)+8px)] scroll-py-1 rounded-xl p-1 select-none empty:invisible',
 | 
				
			||||||
 | 
					          // Invisible border that is only visible in `forced-colors` mode for accessibility purposes
 | 
				
			||||||
 | 
					          'outline outline-transparent focus:outline-hidden',
 | 
				
			||||||
 | 
					          // Handle scrolling when menu won't fit in viewport
 | 
				
			||||||
 | 
					          'overflow-y-scroll overscroll-contain',
 | 
				
			||||||
 | 
					          // Popover background
 | 
				
			||||||
 | 
					          'bg-white/75 backdrop-blur-xl dark:bg-zinc-800/75',
 | 
				
			||||||
 | 
					          // Shadows
 | 
				
			||||||
 | 
					          'shadow-lg ring-1 ring-zinc-950/10 dark:ring-white/10 dark:ring-inset',
 | 
				
			||||||
 | 
					          // Transitions
 | 
				
			||||||
 | 
					          'transition-opacity duration-100 ease-in data-closed:data-leave:opacity-0 data-transition:pointer-events-none'
 | 
				
			||||||
 | 
					        )}
 | 
				
			||||||
 | 
					      >
 | 
				
			||||||
 | 
					        {({ option }) => children(option)}
 | 
				
			||||||
 | 
					      </Headless.ComboboxOptions>
 | 
				
			||||||
 | 
					    </Headless.Combobox>
 | 
				
			||||||
 | 
					  )
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function ComboboxOption<T>({
 | 
				
			||||||
 | 
					  children,
 | 
				
			||||||
 | 
					  className,
 | 
				
			||||||
 | 
					  ...props
 | 
				
			||||||
 | 
					}: { className?: string; children?: React.ReactNode } & Omit<
 | 
				
			||||||
 | 
					  Headless.ComboboxOptionProps<'div', T>,
 | 
				
			||||||
 | 
					  'as' | 'className'
 | 
				
			||||||
 | 
					>) {
 | 
				
			||||||
 | 
					  let sharedClasses = clsx(
 | 
				
			||||||
 | 
					    // Base
 | 
				
			||||||
 | 
					    'flex min-w-0 items-center',
 | 
				
			||||||
 | 
					    // Icons
 | 
				
			||||||
 | 
					    '*:data-[slot=icon]:size-5 *:data-[slot=icon]:shrink-0 sm:*:data-[slot=icon]:size-4',
 | 
				
			||||||
 | 
					    '*:data-[slot=icon]:text-zinc-500 group-data-focus/option:*:data-[slot=icon]:text-white dark:*:data-[slot=icon]:text-zinc-400',
 | 
				
			||||||
 | 
					    'forced-colors:*:data-[slot=icon]:text-[CanvasText] forced-colors:group-data-focus/option:*:data-[slot=icon]:text-[Canvas]',
 | 
				
			||||||
 | 
					    // Avatars
 | 
				
			||||||
 | 
					    '*:data-[slot=avatar]:-mx-0.5 *:data-[slot=avatar]:size-6 sm:*:data-[slot=avatar]:size-5'
 | 
				
			||||||
 | 
					  )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <Headless.ComboboxOption
 | 
				
			||||||
 | 
					      {...props}
 | 
				
			||||||
 | 
					      className={clsx(
 | 
				
			||||||
 | 
					        // Basic layout
 | 
				
			||||||
 | 
					        'group/option grid w-full cursor-default grid-cols-[1fr_--spacing(5)] items-baseline gap-x-2 rounded-lg py-2.5 pr-2 pl-3.5 sm:grid-cols-[1fr_--spacing(4)] sm:py-1.5 sm:pr-2 sm:pl-3',
 | 
				
			||||||
 | 
					        // Typography
 | 
				
			||||||
 | 
					        'text-base/6 text-zinc-950 sm:text-sm/6 dark:text-white forced-colors:text-[CanvasText]',
 | 
				
			||||||
 | 
					        // Focus
 | 
				
			||||||
 | 
					        'outline-hidden data-focus:bg-blue-500 data-focus:text-white',
 | 
				
			||||||
 | 
					        // Forced colors mode
 | 
				
			||||||
 | 
					        'forced-color-adjust-none forced-colors:data-focus:bg-[Highlight] forced-colors:data-focus:text-[HighlightText]',
 | 
				
			||||||
 | 
					        // Disabled
 | 
				
			||||||
 | 
					        'data-disabled:opacity-50'
 | 
				
			||||||
 | 
					      )}
 | 
				
			||||||
 | 
					    >
 | 
				
			||||||
 | 
					      <span className={clsx(className, sharedClasses)}>{children}</span>
 | 
				
			||||||
 | 
					      <svg
 | 
				
			||||||
 | 
					        className="relative col-start-2 hidden size-5 self-center stroke-current group-data-selected/option:inline sm:size-4"
 | 
				
			||||||
 | 
					        viewBox="0 0 16 16"
 | 
				
			||||||
 | 
					        fill="none"
 | 
				
			||||||
 | 
					        aria-hidden="true"
 | 
				
			||||||
 | 
					      >
 | 
				
			||||||
 | 
					        <path d="M4 8.5l3 3L12 4" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" />
 | 
				
			||||||
 | 
					      </svg>
 | 
				
			||||||
 | 
					    </Headless.ComboboxOption>
 | 
				
			||||||
 | 
					  )
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function ComboboxLabel({ className, ...props }: React.ComponentPropsWithoutRef<'span'>) {
 | 
				
			||||||
 | 
					  return <span {...props} className={clsx(className, 'ml-2.5 truncate first:ml-0 sm:ml-2 sm:first:ml-0')} />
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function ComboboxDescription({ className, children, ...props }: React.ComponentPropsWithoutRef<'span'>) {
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <span
 | 
				
			||||||
 | 
					      {...props}
 | 
				
			||||||
 | 
					      className={clsx(
 | 
				
			||||||
 | 
					        className,
 | 
				
			||||||
 | 
					        'flex flex-1 overflow-hidden text-zinc-500 group-data-focus/option:text-white before:w-2 before:min-w-0 before:shrink dark:text-zinc-400'
 | 
				
			||||||
 | 
					      )}
 | 
				
			||||||
 | 
					    >
 | 
				
			||||||
 | 
					      <span className="flex-1 truncate">{children}</span>
 | 
				
			||||||
 | 
					    </span>
 | 
				
			||||||
 | 
					  )
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										37
									
								
								src/components/description-list.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										37
									
								
								src/components/description-list.tsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,37 @@
 | 
				
			|||||||
 | 
					import clsx from 'clsx'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function DescriptionList({ className, ...props }: React.ComponentPropsWithoutRef<'dl'>) {
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <dl
 | 
				
			||||||
 | 
					      {...props}
 | 
				
			||||||
 | 
					      className={clsx(
 | 
				
			||||||
 | 
					        className,
 | 
				
			||||||
 | 
					        'grid grid-cols-1 text-base/6 sm:grid-cols-[min(50%,--spacing(80))_auto] sm:text-sm/6'
 | 
				
			||||||
 | 
					      )}
 | 
				
			||||||
 | 
					    />
 | 
				
			||||||
 | 
					  )
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function DescriptionTerm({ className, ...props }: React.ComponentPropsWithoutRef<'dt'>) {
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <dt
 | 
				
			||||||
 | 
					      {...props}
 | 
				
			||||||
 | 
					      className={clsx(
 | 
				
			||||||
 | 
					        className,
 | 
				
			||||||
 | 
					        'col-start-1 border-t border-zinc-950/5 pt-3 text-zinc-500 first:border-none sm:border-t sm:border-zinc-950/5 sm:py-3 dark:border-white/5 dark:text-zinc-400 sm:dark:border-white/5'
 | 
				
			||||||
 | 
					      )}
 | 
				
			||||||
 | 
					    />
 | 
				
			||||||
 | 
					  )
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function DescriptionDetails({ className, ...props }: React.ComponentPropsWithoutRef<'dd'>) {
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <dd
 | 
				
			||||||
 | 
					      {...props}
 | 
				
			||||||
 | 
					      className={clsx(
 | 
				
			||||||
 | 
					        className,
 | 
				
			||||||
 | 
					        'pt-1 pb-3 text-zinc-950 sm:border-t sm:border-zinc-950/5 sm:py-3 sm:nth-2:border-none dark:text-white dark:sm:border-white/5'
 | 
				
			||||||
 | 
					      )}
 | 
				
			||||||
 | 
					    />
 | 
				
			||||||
 | 
					  )
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										86
									
								
								src/components/dialog.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										86
									
								
								src/components/dialog.tsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,86 @@
 | 
				
			|||||||
 | 
					import * as Headless from '@headlessui/react'
 | 
				
			||||||
 | 
					import clsx from 'clsx'
 | 
				
			||||||
 | 
					import type React from 'react'
 | 
				
			||||||
 | 
					import { Text } from './text'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const sizes = {
 | 
				
			||||||
 | 
					  xs: 'sm:max-w-xs',
 | 
				
			||||||
 | 
					  sm: 'sm:max-w-sm',
 | 
				
			||||||
 | 
					  md: 'sm:max-w-md',
 | 
				
			||||||
 | 
					  lg: 'sm:max-w-lg',
 | 
				
			||||||
 | 
					  xl: 'sm:max-w-xl',
 | 
				
			||||||
 | 
					  '2xl': 'sm:max-w-2xl',
 | 
				
			||||||
 | 
					  '3xl': 'sm:max-w-3xl',
 | 
				
			||||||
 | 
					  '4xl': 'sm:max-w-4xl',
 | 
				
			||||||
 | 
					  '5xl': 'sm:max-w-5xl',
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function Dialog({
 | 
				
			||||||
 | 
					  size = 'lg',
 | 
				
			||||||
 | 
					  className,
 | 
				
			||||||
 | 
					  children,
 | 
				
			||||||
 | 
					  ...props
 | 
				
			||||||
 | 
					}: { size?: keyof typeof sizes; className?: string; children: React.ReactNode } & Omit<
 | 
				
			||||||
 | 
					  Headless.DialogProps,
 | 
				
			||||||
 | 
					  'as' | 'className'
 | 
				
			||||||
 | 
					>) {
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <Headless.Dialog {...props}>
 | 
				
			||||||
 | 
					      <Headless.DialogBackdrop
 | 
				
			||||||
 | 
					        transition
 | 
				
			||||||
 | 
					        className="fixed inset-0 flex w-screen justify-center overflow-y-auto bg-zinc-950/25 px-2 py-2 transition duration-100 focus:outline-0 data-closed:opacity-0 data-enter:ease-out data-leave:ease-in sm:px-6 sm:py-8 lg:px-8 lg:py-16 dark:bg-zinc-950/50"
 | 
				
			||||||
 | 
					      />
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      <div className="fixed inset-0 w-screen overflow-y-auto pt-6 sm:pt-0">
 | 
				
			||||||
 | 
					        <div className="grid min-h-full grid-rows-[1fr_auto] justify-items-center sm:grid-rows-[1fr_auto_3fr] sm:p-4">
 | 
				
			||||||
 | 
					          <Headless.DialogPanel
 | 
				
			||||||
 | 
					            transition
 | 
				
			||||||
 | 
					            className={clsx(
 | 
				
			||||||
 | 
					              className,
 | 
				
			||||||
 | 
					              sizes[size],
 | 
				
			||||||
 | 
					              'row-start-2 w-full min-w-0 rounded-t-3xl bg-white p-(--gutter) shadow-lg ring-1 ring-zinc-950/10 [--gutter:--spacing(8)] sm:mb-auto sm:rounded-2xl dark:bg-zinc-900 dark:ring-white/10 forced-colors:outline',
 | 
				
			||||||
 | 
					              'transition duration-100 will-change-transform data-closed:translate-y-12 data-closed:opacity-0 data-enter:ease-out data-leave:ease-in sm:data-closed:translate-y-0 sm:data-closed:data-enter:scale-95'
 | 
				
			||||||
 | 
					            )}
 | 
				
			||||||
 | 
					          >
 | 
				
			||||||
 | 
					            {children}
 | 
				
			||||||
 | 
					          </Headless.DialogPanel>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					    </Headless.Dialog>
 | 
				
			||||||
 | 
					  )
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function DialogTitle({
 | 
				
			||||||
 | 
					  className,
 | 
				
			||||||
 | 
					  ...props
 | 
				
			||||||
 | 
					}: { className?: string } & Omit<Headless.DialogTitleProps, 'as' | 'className'>) {
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <Headless.DialogTitle
 | 
				
			||||||
 | 
					      {...props}
 | 
				
			||||||
 | 
					      className={clsx(className, 'text-lg/6 font-semibold text-balance text-zinc-950 sm:text-base/6 dark:text-white')}
 | 
				
			||||||
 | 
					    />
 | 
				
			||||||
 | 
					  )
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function DialogDescription({
 | 
				
			||||||
 | 
					  className,
 | 
				
			||||||
 | 
					  ...props
 | 
				
			||||||
 | 
					}: { className?: string } & Omit<Headless.DescriptionProps<typeof Text>, 'as' | 'className'>) {
 | 
				
			||||||
 | 
					  return <Headless.Description as={Text} {...props} className={clsx(className, 'mt-2 text-pretty')} />
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function DialogBody({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) {
 | 
				
			||||||
 | 
					  return <div {...props} className={clsx(className, 'mt-6')} />
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function DialogActions({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) {
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <div
 | 
				
			||||||
 | 
					      {...props}
 | 
				
			||||||
 | 
					      className={clsx(
 | 
				
			||||||
 | 
					        className,
 | 
				
			||||||
 | 
					        'mt-8 flex flex-col-reverse items-center justify-end gap-3 *:w-full sm:flex-row sm:*:w-auto'
 | 
				
			||||||
 | 
					      )}
 | 
				
			||||||
 | 
					    />
 | 
				
			||||||
 | 
					  )
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										20
									
								
								src/components/divider.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								src/components/divider.tsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,20 @@
 | 
				
			|||||||
 | 
					import clsx from 'clsx'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function Divider({
 | 
				
			||||||
 | 
					  soft = false,
 | 
				
			||||||
 | 
					  className,
 | 
				
			||||||
 | 
					  ...props
 | 
				
			||||||
 | 
					}: { soft?: boolean } & React.ComponentPropsWithoutRef<'hr'>) {
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <hr
 | 
				
			||||||
 | 
					      role="presentation"
 | 
				
			||||||
 | 
					      {...props}
 | 
				
			||||||
 | 
					      className={clsx(
 | 
				
			||||||
 | 
					        className,
 | 
				
			||||||
 | 
					        'w-full border-t',
 | 
				
			||||||
 | 
					        soft && 'border-zinc-950/5 dark:border-white/5',
 | 
				
			||||||
 | 
					        !soft && 'border-zinc-950/10 dark:border-white/10'
 | 
				
			||||||
 | 
					      )}
 | 
				
			||||||
 | 
					    />
 | 
				
			||||||
 | 
					  )
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										188
									
								
								src/components/dropdown.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										188
									
								
								src/components/dropdown.tsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,188 @@
 | 
				
			|||||||
 | 
					'use client'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import * as Headless from '@headlessui/react'
 | 
				
			||||||
 | 
					import clsx from 'clsx'
 | 
				
			||||||
 | 
					import type React from 'react'
 | 
				
			||||||
 | 
					import { Button } from './button'
 | 
				
			||||||
 | 
					import { Link } from './link'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function Dropdown(props: Headless.MenuProps) {
 | 
				
			||||||
 | 
					  return <Headless.Menu {...props} />
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function DropdownButton<T extends React.ElementType = typeof Button>({
 | 
				
			||||||
 | 
					  as = Button,
 | 
				
			||||||
 | 
					  ...props
 | 
				
			||||||
 | 
					}: { className?: string } & Omit<Headless.MenuButtonProps<T>, 'className'>) {
 | 
				
			||||||
 | 
					  return <Headless.MenuButton as={as} {...props} />
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function DropdownMenu({
 | 
				
			||||||
 | 
					  anchor = 'bottom',
 | 
				
			||||||
 | 
					  className,
 | 
				
			||||||
 | 
					  ...props
 | 
				
			||||||
 | 
					}: { className?: string } & Omit<Headless.MenuItemsProps, 'as' | 'className'>) {
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <Headless.MenuItems
 | 
				
			||||||
 | 
					      {...props}
 | 
				
			||||||
 | 
					      transition
 | 
				
			||||||
 | 
					      anchor={anchor}
 | 
				
			||||||
 | 
					      className={clsx(
 | 
				
			||||||
 | 
					        className,
 | 
				
			||||||
 | 
					        // Anchor positioning
 | 
				
			||||||
 | 
					        '[--anchor-gap:--spacing(2)] [--anchor-padding:--spacing(1)] data-[anchor~=end]:[--anchor-offset:6px] data-[anchor~=start]:[--anchor-offset:-6px] sm:data-[anchor~=end]:[--anchor-offset:4px] sm:data-[anchor~=start]:[--anchor-offset:-4px]',
 | 
				
			||||||
 | 
					        // Base styles
 | 
				
			||||||
 | 
					        'isolate w-max rounded-xl p-1',
 | 
				
			||||||
 | 
					        // Invisible border that is only visible in `forced-colors` mode for accessibility purposes
 | 
				
			||||||
 | 
					        'outline outline-transparent focus:outline-hidden',
 | 
				
			||||||
 | 
					        // Handle scrolling when menu won't fit in viewport
 | 
				
			||||||
 | 
					        'overflow-y-auto',
 | 
				
			||||||
 | 
					        // Popover background
 | 
				
			||||||
 | 
					        'bg-white/75 backdrop-blur-xl dark:bg-zinc-800/75',
 | 
				
			||||||
 | 
					        // Shadows
 | 
				
			||||||
 | 
					        'shadow-lg ring-1 ring-zinc-950/10 dark:ring-white/10 dark:ring-inset',
 | 
				
			||||||
 | 
					        // Define grid at the menu level if subgrid is supported
 | 
				
			||||||
 | 
					        'supports-[grid-template-columns:subgrid]:grid supports-[grid-template-columns:subgrid]:grid-cols-[auto_1fr_1.5rem_0.5rem_auto]',
 | 
				
			||||||
 | 
					        // Transitions
 | 
				
			||||||
 | 
					        'transition data-leave:duration-100 data-leave:ease-in data-closed:data-leave:opacity-0'
 | 
				
			||||||
 | 
					      )}
 | 
				
			||||||
 | 
					    />
 | 
				
			||||||
 | 
					  )
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function DropdownItem({
 | 
				
			||||||
 | 
					  className,
 | 
				
			||||||
 | 
					  ...props
 | 
				
			||||||
 | 
					}: { className?: string } & (
 | 
				
			||||||
 | 
					  | Omit<Headless.MenuItemProps<'button'>, 'as' | 'className'>
 | 
				
			||||||
 | 
					  | Omit<Headless.MenuItemProps<typeof Link>, 'as' | 'className'>
 | 
				
			||||||
 | 
					)) {
 | 
				
			||||||
 | 
					  let classes = clsx(
 | 
				
			||||||
 | 
					    className,
 | 
				
			||||||
 | 
					    // Base styles
 | 
				
			||||||
 | 
					    'group cursor-default rounded-lg px-3.5 py-2.5 focus:outline-hidden sm:px-3 sm:py-1.5',
 | 
				
			||||||
 | 
					    // Text styles
 | 
				
			||||||
 | 
					    'text-left text-base/6 text-zinc-950 sm:text-sm/6 dark:text-white forced-colors:text-[CanvasText]',
 | 
				
			||||||
 | 
					    // Focus
 | 
				
			||||||
 | 
					    'data-focus:bg-blue-500 data-focus:text-white',
 | 
				
			||||||
 | 
					    // Disabled state
 | 
				
			||||||
 | 
					    'data-disabled:opacity-50',
 | 
				
			||||||
 | 
					    // Forced colors mode
 | 
				
			||||||
 | 
					    'forced-color-adjust-none forced-colors:data-focus:bg-[Highlight] forced-colors:data-focus:text-[HighlightText] forced-colors:data-focus:*:data-[slot=icon]:text-[HighlightText]',
 | 
				
			||||||
 | 
					    // Use subgrid when available but fallback to an explicit grid layout if not
 | 
				
			||||||
 | 
					    'col-span-full grid grid-cols-[auto_1fr_1.5rem_0.5rem_auto] items-center supports-[grid-template-columns:subgrid]:grid-cols-subgrid',
 | 
				
			||||||
 | 
					    // Icons
 | 
				
			||||||
 | 
					    '*:data-[slot=icon]:col-start-1 *:data-[slot=icon]:row-start-1 *:data-[slot=icon]:mr-2.5 *:data-[slot=icon]:-ml-0.5 *:data-[slot=icon]:size-5 sm:*:data-[slot=icon]:mr-2 sm:*:data-[slot=icon]:size-4',
 | 
				
			||||||
 | 
					    '*:data-[slot=icon]:text-zinc-500 data-focus:*:data-[slot=icon]:text-white dark:*:data-[slot=icon]:text-zinc-400 dark:data-focus:*:data-[slot=icon]:text-white',
 | 
				
			||||||
 | 
					    // Avatar
 | 
				
			||||||
 | 
					    '*:data-[slot=avatar]:mr-2.5 *:data-[slot=avatar]:-ml-1 *:data-[slot=avatar]:size-6 sm:*:data-[slot=avatar]:mr-2 sm:*:data-[slot=avatar]:size-5'
 | 
				
			||||||
 | 
					  )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  return 'href' in props ? (
 | 
				
			||||||
 | 
					    <Headless.MenuItem as={Link} {...props} className={classes} />
 | 
				
			||||||
 | 
					  ) : (
 | 
				
			||||||
 | 
					    <Headless.MenuItem as="button" type="button" {...props} className={classes} />
 | 
				
			||||||
 | 
					  )
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function DropdownHeader({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) {
 | 
				
			||||||
 | 
					  return <div {...props} className={clsx(className, 'col-span-5 px-3.5 pt-2.5 pb-1 sm:px-3')} />
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function DropdownSection({
 | 
				
			||||||
 | 
					  className,
 | 
				
			||||||
 | 
					  ...props
 | 
				
			||||||
 | 
					}: { className?: string } & Omit<Headless.MenuSectionProps, 'as' | 'className'>) {
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <Headless.MenuSection
 | 
				
			||||||
 | 
					      {...props}
 | 
				
			||||||
 | 
					      className={clsx(
 | 
				
			||||||
 | 
					        className,
 | 
				
			||||||
 | 
					        // Define grid at the section level instead of the item level if subgrid is supported
 | 
				
			||||||
 | 
					        'col-span-full supports-[grid-template-columns:subgrid]:grid supports-[grid-template-columns:subgrid]:grid-cols-[auto_1fr_1.5rem_0.5rem_auto]'
 | 
				
			||||||
 | 
					      )}
 | 
				
			||||||
 | 
					    />
 | 
				
			||||||
 | 
					  )
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function DropdownHeading({
 | 
				
			||||||
 | 
					  className,
 | 
				
			||||||
 | 
					  ...props
 | 
				
			||||||
 | 
					}: { className?: string } & Omit<Headless.MenuHeadingProps, 'as' | 'className'>) {
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <Headless.MenuHeading
 | 
				
			||||||
 | 
					      {...props}
 | 
				
			||||||
 | 
					      className={clsx(
 | 
				
			||||||
 | 
					        className,
 | 
				
			||||||
 | 
					        'col-span-full grid grid-cols-[1fr_auto] gap-x-12 px-3.5 pt-2 pb-1 text-sm/5 font-medium text-zinc-500 sm:px-3 sm:text-xs/5 dark:text-zinc-400'
 | 
				
			||||||
 | 
					      )}
 | 
				
			||||||
 | 
					    />
 | 
				
			||||||
 | 
					  )
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function DropdownDivider({
 | 
				
			||||||
 | 
					  className,
 | 
				
			||||||
 | 
					  ...props
 | 
				
			||||||
 | 
					}: { className?: string } & Omit<Headless.MenuSeparatorProps, 'as' | 'className'>) {
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <Headless.MenuSeparator
 | 
				
			||||||
 | 
					      {...props}
 | 
				
			||||||
 | 
					      className={clsx(
 | 
				
			||||||
 | 
					        className,
 | 
				
			||||||
 | 
					        'col-span-full mx-3.5 my-1 h-px border-0 bg-zinc-950/5 sm:mx-3 dark:bg-white/10 forced-colors:bg-[CanvasText]'
 | 
				
			||||||
 | 
					      )}
 | 
				
			||||||
 | 
					    />
 | 
				
			||||||
 | 
					  )
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function DropdownLabel({
 | 
				
			||||||
 | 
					  className,
 | 
				
			||||||
 | 
					  ...props
 | 
				
			||||||
 | 
					}: { className?: string } & Omit<Headless.LabelProps, 'as' | 'className'>) {
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <Headless.Label {...props} data-slot="label" className={clsx(className, 'col-start-2 row-start-1')} {...props} />
 | 
				
			||||||
 | 
					  )
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function DropdownDescription({
 | 
				
			||||||
 | 
					  className,
 | 
				
			||||||
 | 
					  ...props
 | 
				
			||||||
 | 
					}: { className?: string } & Omit<Headless.DescriptionProps, 'as' | 'className'>) {
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <Headless.Description
 | 
				
			||||||
 | 
					      data-slot="description"
 | 
				
			||||||
 | 
					      {...props}
 | 
				
			||||||
 | 
					      className={clsx(
 | 
				
			||||||
 | 
					        className,
 | 
				
			||||||
 | 
					        'col-span-2 col-start-2 row-start-2 text-sm/5 text-zinc-500 group-data-focus:text-white sm:text-xs/5 dark:text-zinc-400 forced-colors:group-data-focus:text-[HighlightText]'
 | 
				
			||||||
 | 
					      )}
 | 
				
			||||||
 | 
					    />
 | 
				
			||||||
 | 
					  )
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function DropdownShortcut({
 | 
				
			||||||
 | 
					  keys,
 | 
				
			||||||
 | 
					  className,
 | 
				
			||||||
 | 
					  ...props
 | 
				
			||||||
 | 
					}: { keys: string | string[]; className?: string } & Omit<Headless.DescriptionProps<'kbd'>, 'as' | 'className'>) {
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <Headless.Description
 | 
				
			||||||
 | 
					      as="kbd"
 | 
				
			||||||
 | 
					      {...props}
 | 
				
			||||||
 | 
					      className={clsx(className, 'col-start-5 row-start-1 flex justify-self-end')}
 | 
				
			||||||
 | 
					    >
 | 
				
			||||||
 | 
					      {(Array.isArray(keys) ? keys : keys.split('')).map((char, index) => (
 | 
				
			||||||
 | 
					        <kbd
 | 
				
			||||||
 | 
					          key={index}
 | 
				
			||||||
 | 
					          className={clsx([
 | 
				
			||||||
 | 
					            'min-w-[2ch] text-center font-sans text-zinc-400 capitalize group-data-focus:text-white forced-colors:group-data-focus:text-[HighlightText]',
 | 
				
			||||||
 | 
					            // Make sure key names that are longer than one character (like "Tab") have extra space
 | 
				
			||||||
 | 
					            index > 0 && char.length > 1 && 'pl-1',
 | 
				
			||||||
 | 
					          ])}
 | 
				
			||||||
 | 
					        >
 | 
				
			||||||
 | 
					          {char}
 | 
				
			||||||
 | 
					        </kbd>
 | 
				
			||||||
 | 
					      ))}
 | 
				
			||||||
 | 
					    </Headless.Description>
 | 
				
			||||||
 | 
					  )
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										91
									
								
								src/components/fieldset.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										91
									
								
								src/components/fieldset.tsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,91 @@
 | 
				
			|||||||
 | 
					import * as Headless from '@headlessui/react'
 | 
				
			||||||
 | 
					import clsx from 'clsx'
 | 
				
			||||||
 | 
					import type React from 'react'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function Fieldset({
 | 
				
			||||||
 | 
					  className,
 | 
				
			||||||
 | 
					  ...props
 | 
				
			||||||
 | 
					}: { className?: string } & Omit<Headless.FieldsetProps, 'as' | 'className'>) {
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <Headless.Fieldset
 | 
				
			||||||
 | 
					      {...props}
 | 
				
			||||||
 | 
					      className={clsx(className, '*:data-[slot=text]:mt-1 [&>*+[data-slot=control]]:mt-6')}
 | 
				
			||||||
 | 
					    />
 | 
				
			||||||
 | 
					  )
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function Legend({
 | 
				
			||||||
 | 
					  className,
 | 
				
			||||||
 | 
					  ...props
 | 
				
			||||||
 | 
					}: { className?: string } & Omit<Headless.LegendProps, 'as' | 'className'>) {
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <Headless.Legend
 | 
				
			||||||
 | 
					      data-slot="legend"
 | 
				
			||||||
 | 
					      {...props}
 | 
				
			||||||
 | 
					      className={clsx(
 | 
				
			||||||
 | 
					        className,
 | 
				
			||||||
 | 
					        'text-base/6 font-semibold text-zinc-950 data-disabled:opacity-50 sm:text-sm/6 dark:text-white'
 | 
				
			||||||
 | 
					      )}
 | 
				
			||||||
 | 
					    />
 | 
				
			||||||
 | 
					  )
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function FieldGroup({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) {
 | 
				
			||||||
 | 
					  return <div data-slot="control" {...props} className={clsx(className, 'space-y-8')} />
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function Field({ className, ...props }: { className?: string } & Omit<Headless.FieldProps, 'as' | 'className'>) {
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <Headless.Field
 | 
				
			||||||
 | 
					      {...props}
 | 
				
			||||||
 | 
					      className={clsx(
 | 
				
			||||||
 | 
					        className,
 | 
				
			||||||
 | 
					        '[&>[data-slot=label]+[data-slot=control]]:mt-3',
 | 
				
			||||||
 | 
					        '[&>[data-slot=label]+[data-slot=description]]:mt-1',
 | 
				
			||||||
 | 
					        '[&>[data-slot=description]+[data-slot=control]]:mt-3',
 | 
				
			||||||
 | 
					        '[&>[data-slot=control]+[data-slot=description]]:mt-3',
 | 
				
			||||||
 | 
					        '[&>[data-slot=control]+[data-slot=error]]:mt-3',
 | 
				
			||||||
 | 
					        '*:data-[slot=label]:font-medium'
 | 
				
			||||||
 | 
					      )}
 | 
				
			||||||
 | 
					    />
 | 
				
			||||||
 | 
					  )
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function Label({ className, ...props }: { className?: string } & Omit<Headless.LabelProps, 'as' | 'className'>) {
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <Headless.Label
 | 
				
			||||||
 | 
					      data-slot="label"
 | 
				
			||||||
 | 
					      {...props}
 | 
				
			||||||
 | 
					      className={clsx(
 | 
				
			||||||
 | 
					        className,
 | 
				
			||||||
 | 
					        'text-base/6 text-zinc-950 select-none data-disabled:opacity-50 sm:text-sm/6 dark:text-white'
 | 
				
			||||||
 | 
					      )}
 | 
				
			||||||
 | 
					    />
 | 
				
			||||||
 | 
					  )
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function Description({
 | 
				
			||||||
 | 
					  className,
 | 
				
			||||||
 | 
					  ...props
 | 
				
			||||||
 | 
					}: { className?: string } & Omit<Headless.DescriptionProps, 'as' | 'className'>) {
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <Headless.Description
 | 
				
			||||||
 | 
					      data-slot="description"
 | 
				
			||||||
 | 
					      {...props}
 | 
				
			||||||
 | 
					      className={clsx(className, 'text-base/6 text-zinc-500 data-disabled:opacity-50 sm:text-sm/6 dark:text-zinc-400')}
 | 
				
			||||||
 | 
					    />
 | 
				
			||||||
 | 
					  )
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function ErrorMessage({
 | 
				
			||||||
 | 
					  className,
 | 
				
			||||||
 | 
					  ...props
 | 
				
			||||||
 | 
					}: { className?: string } & Omit<Headless.DescriptionProps, 'as' | 'className'>) {
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <Headless.Description
 | 
				
			||||||
 | 
					      data-slot="error"
 | 
				
			||||||
 | 
					      {...props}
 | 
				
			||||||
 | 
					      className={clsx(className, 'text-base/6 text-red-600 data-disabled:opacity-50 sm:text-sm/6 dark:text-red-500')}
 | 
				
			||||||
 | 
					    />
 | 
				
			||||||
 | 
					  )
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										27
									
								
								src/components/heading.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								src/components/heading.tsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,27 @@
 | 
				
			|||||||
 | 
					import clsx from 'clsx'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					type HeadingProps = { level?: 1 | 2 | 3 | 4 | 5 | 6 } & React.ComponentPropsWithoutRef<
 | 
				
			||||||
 | 
					  'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'
 | 
				
			||||||
 | 
					>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function Heading({ className, level = 1, ...props }: HeadingProps) {
 | 
				
			||||||
 | 
					  let Element: `h${typeof level}` = `h${level}`
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <Element
 | 
				
			||||||
 | 
					      {...props}
 | 
				
			||||||
 | 
					      className={clsx(className, 'text-2xl/8 font-semibold text-zinc-950 sm:text-xl/8 dark:text-white')}
 | 
				
			||||||
 | 
					    />
 | 
				
			||||||
 | 
					  )
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function Subheading({ className, level = 2, ...props }: HeadingProps) {
 | 
				
			||||||
 | 
					  let Element: `h${typeof level}` = `h${level}`
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <Element
 | 
				
			||||||
 | 
					      {...props}
 | 
				
			||||||
 | 
					      className={clsx(className, 'text-base/7 font-semibold text-zinc-950 sm:text-sm/6 dark:text-white')}
 | 
				
			||||||
 | 
					    />
 | 
				
			||||||
 | 
					  )
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										94
									
								
								src/components/input.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										94
									
								
								src/components/input.tsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,94 @@
 | 
				
			|||||||
 | 
					import * as Headless from '@headlessui/react'
 | 
				
			||||||
 | 
					import clsx from 'clsx'
 | 
				
			||||||
 | 
					import React, { forwardRef } from 'react'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function InputGroup({ children }: React.ComponentPropsWithoutRef<'span'>) {
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <span
 | 
				
			||||||
 | 
					      data-slot="control"
 | 
				
			||||||
 | 
					      className={clsx(
 | 
				
			||||||
 | 
					        'relative isolate block',
 | 
				
			||||||
 | 
					        'has-[[data-slot=icon]:first-child]:[&_input]:pl-10 has-[[data-slot=icon]:last-child]:[&_input]:pr-10 sm:has-[[data-slot=icon]:first-child]:[&_input]:pl-8 sm:has-[[data-slot=icon]:last-child]:[&_input]:pr-8',
 | 
				
			||||||
 | 
					        '*:data-[slot=icon]:pointer-events-none *:data-[slot=icon]:absolute *:data-[slot=icon]:top-3 *:data-[slot=icon]:z-10 *:data-[slot=icon]:size-5 sm:*:data-[slot=icon]:top-2.5 sm:*:data-[slot=icon]:size-4',
 | 
				
			||||||
 | 
					        '[&>[data-slot=icon]:first-child]:left-3 sm:[&>[data-slot=icon]:first-child]:left-2.5 [&>[data-slot=icon]:last-child]:right-3 sm:[&>[data-slot=icon]:last-child]:right-2.5',
 | 
				
			||||||
 | 
					        '*:data-[slot=icon]:text-zinc-500 dark:*:data-[slot=icon]:text-zinc-400'
 | 
				
			||||||
 | 
					      )}
 | 
				
			||||||
 | 
					    >
 | 
				
			||||||
 | 
					      {children}
 | 
				
			||||||
 | 
					    </span>
 | 
				
			||||||
 | 
					  )
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const dateTypes = ['date', 'datetime-local', 'month', 'time', 'week']
 | 
				
			||||||
 | 
					type DateType = (typeof dateTypes)[number]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const Input = forwardRef(function Input(
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
 | 
					    className,
 | 
				
			||||||
 | 
					    ...props
 | 
				
			||||||
 | 
					  }: {
 | 
				
			||||||
 | 
					    className?: string
 | 
				
			||||||
 | 
					    type?: 'email' | 'number' | 'password' | 'search' | 'tel' | 'text' | 'url' | DateType
 | 
				
			||||||
 | 
					  } & Omit<Headless.InputProps, 'as' | 'className'>,
 | 
				
			||||||
 | 
					  ref: React.ForwardedRef<HTMLInputElement>
 | 
				
			||||||
 | 
					) {
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <span
 | 
				
			||||||
 | 
					      data-slot="control"
 | 
				
			||||||
 | 
					      className={clsx([
 | 
				
			||||||
 | 
					        className,
 | 
				
			||||||
 | 
					        // Basic layout
 | 
				
			||||||
 | 
					        'relative block w-full',
 | 
				
			||||||
 | 
					        // Background color + shadow applied to inset pseudo element, so shadow blends with border in light mode
 | 
				
			||||||
 | 
					        'before:absolute before:inset-px before:rounded-[calc(var(--radius-lg)-1px)] before:bg-white before:shadow-sm',
 | 
				
			||||||
 | 
					        // Background color is moved to control and shadow is removed in dark mode so hide `before` pseudo
 | 
				
			||||||
 | 
					        'dark:before:hidden',
 | 
				
			||||||
 | 
					        // Focus ring
 | 
				
			||||||
 | 
					        'after:pointer-events-none after:absolute after:inset-0 after:rounded-lg after:ring-transparent after:ring-inset sm:focus-within:after:ring-2 sm:focus-within:after:ring-blue-500',
 | 
				
			||||||
 | 
					        // Disabled state
 | 
				
			||||||
 | 
					        'has-data-disabled:opacity-50 has-data-disabled:before:bg-zinc-950/5 has-data-disabled:before:shadow-none',
 | 
				
			||||||
 | 
					        // Invalid state
 | 
				
			||||||
 | 
					        'has-data-invalid:before:shadow-red-500/10',
 | 
				
			||||||
 | 
					      ])}
 | 
				
			||||||
 | 
					    >
 | 
				
			||||||
 | 
					      <Headless.Input
 | 
				
			||||||
 | 
					        ref={ref}
 | 
				
			||||||
 | 
					        {...props}
 | 
				
			||||||
 | 
					        className={clsx([
 | 
				
			||||||
 | 
					          // Date classes
 | 
				
			||||||
 | 
					          props.type &&
 | 
				
			||||||
 | 
					            dateTypes.includes(props.type) && [
 | 
				
			||||||
 | 
					              '[&::-webkit-datetime-edit-fields-wrapper]:p-0',
 | 
				
			||||||
 | 
					              '[&::-webkit-date-and-time-value]:min-h-[1.5em]',
 | 
				
			||||||
 | 
					              '[&::-webkit-datetime-edit]:inline-flex',
 | 
				
			||||||
 | 
					              '[&::-webkit-datetime-edit]:p-0',
 | 
				
			||||||
 | 
					              '[&::-webkit-datetime-edit-year-field]:p-0',
 | 
				
			||||||
 | 
					              '[&::-webkit-datetime-edit-month-field]:p-0',
 | 
				
			||||||
 | 
					              '[&::-webkit-datetime-edit-day-field]:p-0',
 | 
				
			||||||
 | 
					              '[&::-webkit-datetime-edit-hour-field]:p-0',
 | 
				
			||||||
 | 
					              '[&::-webkit-datetime-edit-minute-field]:p-0',
 | 
				
			||||||
 | 
					              '[&::-webkit-datetime-edit-second-field]:p-0',
 | 
				
			||||||
 | 
					              '[&::-webkit-datetime-edit-millisecond-field]:p-0',
 | 
				
			||||||
 | 
					              '[&::-webkit-datetime-edit-meridiem-field]:p-0',
 | 
				
			||||||
 | 
					            ],
 | 
				
			||||||
 | 
					          // Basic layout
 | 
				
			||||||
 | 
					          'relative block w-full appearance-none rounded-lg px-[calc(--spacing(3.5)-1px)] py-[calc(--spacing(2.5)-1px)] sm:px-[calc(--spacing(3)-1px)] sm:py-[calc(--spacing(1.5)-1px)]',
 | 
				
			||||||
 | 
					          // Typography
 | 
				
			||||||
 | 
					          'text-base/6 text-zinc-950 placeholder:text-zinc-500 sm:text-sm/6 dark:text-white',
 | 
				
			||||||
 | 
					          // Border
 | 
				
			||||||
 | 
					          'border border-zinc-950/10 data-hover:border-zinc-950/20 dark:border-white/10 dark:data-hover:border-white/20',
 | 
				
			||||||
 | 
					          // Background color
 | 
				
			||||||
 | 
					          'bg-transparent dark:bg-white/5',
 | 
				
			||||||
 | 
					          // Hide default focus styles
 | 
				
			||||||
 | 
					          'focus:outline-hidden',
 | 
				
			||||||
 | 
					          // Invalid state
 | 
				
			||||||
 | 
					          'data-invalid:border-red-500 data-invalid:data-hover:border-red-500 dark:data-invalid:border-red-500 dark:data-invalid:data-hover:border-red-500',
 | 
				
			||||||
 | 
					          // Disabled state
 | 
				
			||||||
 | 
					          'data-disabled:border-zinc-950/20 dark:data-disabled:border-white/15 dark:data-disabled:bg-white/[2.5%] dark:data-hover:data-disabled:border-white/15',
 | 
				
			||||||
 | 
					          // System icons
 | 
				
			||||||
 | 
					          'dark:[color-scheme:dark]',
 | 
				
			||||||
 | 
					        ])}
 | 
				
			||||||
 | 
					      />
 | 
				
			||||||
 | 
					    </span>
 | 
				
			||||||
 | 
					  )
 | 
				
			||||||
 | 
					})
 | 
				
			||||||
							
								
								
									
										21
									
								
								src/components/link.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								src/components/link.tsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,21 @@
 | 
				
			|||||||
 | 
					/**
 | 
				
			||||||
 | 
					 * TODO: Update this component to use your client-side framework's link
 | 
				
			||||||
 | 
					 * component. We've provided examples of how to do this for Next.js, Remix, and
 | 
				
			||||||
 | 
					 * Inertia.js in the Catalyst documentation:
 | 
				
			||||||
 | 
					 *
 | 
				
			||||||
 | 
					 * https://catalyst.tailwindui.com/docs#client-side-router-integration
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import * as Headless from '@headlessui/react'
 | 
				
			||||||
 | 
					import React, { forwardRef } from 'react'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const Link = forwardRef(function Link(
 | 
				
			||||||
 | 
					  props: { href: string } & React.ComponentPropsWithoutRef<'a'>,
 | 
				
			||||||
 | 
					  ref: React.ForwardedRef<HTMLAnchorElement>
 | 
				
			||||||
 | 
					) {
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <Headless.DataInteractive>
 | 
				
			||||||
 | 
					      <a {...props} ref={ref} />
 | 
				
			||||||
 | 
					    </Headless.DataInteractive>
 | 
				
			||||||
 | 
					  )
 | 
				
			||||||
 | 
					})
 | 
				
			||||||
							
								
								
									
										177
									
								
								src/components/listbox.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										177
									
								
								src/components/listbox.tsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,177 @@
 | 
				
			|||||||
 | 
					'use client'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import * as Headless from '@headlessui/react'
 | 
				
			||||||
 | 
					import clsx from 'clsx'
 | 
				
			||||||
 | 
					import { Fragment } from 'react'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function Listbox<T>({
 | 
				
			||||||
 | 
					  className,
 | 
				
			||||||
 | 
					  placeholder,
 | 
				
			||||||
 | 
					  autoFocus,
 | 
				
			||||||
 | 
					  'aria-label': ariaLabel,
 | 
				
			||||||
 | 
					  children: options,
 | 
				
			||||||
 | 
					  ...props
 | 
				
			||||||
 | 
					}: {
 | 
				
			||||||
 | 
					  className?: string
 | 
				
			||||||
 | 
					  placeholder?: React.ReactNode
 | 
				
			||||||
 | 
					  autoFocus?: boolean
 | 
				
			||||||
 | 
					  'aria-label'?: string
 | 
				
			||||||
 | 
					  children?: React.ReactNode
 | 
				
			||||||
 | 
					} & Omit<Headless.ListboxProps<typeof Fragment, T>, 'as' | 'multiple'>) {
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <Headless.Listbox {...props} multiple={false}>
 | 
				
			||||||
 | 
					      <Headless.ListboxButton
 | 
				
			||||||
 | 
					        autoFocus={autoFocus}
 | 
				
			||||||
 | 
					        data-slot="control"
 | 
				
			||||||
 | 
					        aria-label={ariaLabel}
 | 
				
			||||||
 | 
					        className={clsx([
 | 
				
			||||||
 | 
					          className,
 | 
				
			||||||
 | 
					          // Basic layout
 | 
				
			||||||
 | 
					          'group relative block w-full',
 | 
				
			||||||
 | 
					          // Background color + shadow applied to inset pseudo element, so shadow blends with border in light mode
 | 
				
			||||||
 | 
					          'before:absolute before:inset-px before:rounded-[calc(var(--radius-lg)-1px)] before:bg-white before:shadow-sm',
 | 
				
			||||||
 | 
					          // Background color is moved to control and shadow is removed in dark mode so hide `before` pseudo
 | 
				
			||||||
 | 
					          'dark:before:hidden',
 | 
				
			||||||
 | 
					          // Hide default focus styles
 | 
				
			||||||
 | 
					          'focus:outline-hidden',
 | 
				
			||||||
 | 
					          // Focus ring
 | 
				
			||||||
 | 
					          'after:pointer-events-none after:absolute after:inset-0 after:rounded-lg after:ring-transparent after:ring-inset data-focus:after:ring-2 data-focus:after:ring-blue-500',
 | 
				
			||||||
 | 
					          // Disabled state
 | 
				
			||||||
 | 
					          'data-disabled:opacity-50 data-disabled:before:bg-zinc-950/5 data-disabled:before:shadow-none',
 | 
				
			||||||
 | 
					        ])}
 | 
				
			||||||
 | 
					      >
 | 
				
			||||||
 | 
					        <Headless.ListboxSelectedOption
 | 
				
			||||||
 | 
					          as="span"
 | 
				
			||||||
 | 
					          options={options}
 | 
				
			||||||
 | 
					          placeholder={placeholder && <span className="block truncate text-zinc-500">{placeholder}</span>}
 | 
				
			||||||
 | 
					          className={clsx([
 | 
				
			||||||
 | 
					            // Basic layout
 | 
				
			||||||
 | 
					            'relative block w-full appearance-none rounded-lg py-[calc(--spacing(2.5)-1px)] sm:py-[calc(--spacing(1.5)-1px)]',
 | 
				
			||||||
 | 
					            // Set minimum height for when no value is selected
 | 
				
			||||||
 | 
					            'min-h-11 sm:min-h-9',
 | 
				
			||||||
 | 
					            // Horizontal padding
 | 
				
			||||||
 | 
					            'pr-[calc(--spacing(7)-1px)] pl-[calc(--spacing(3.5)-1px)] sm:pl-[calc(--spacing(3)-1px)]',
 | 
				
			||||||
 | 
					            // Typography
 | 
				
			||||||
 | 
					            'text-left text-base/6 text-zinc-950 placeholder:text-zinc-500 sm:text-sm/6 dark:text-white forced-colors:text-[CanvasText]',
 | 
				
			||||||
 | 
					            // Border
 | 
				
			||||||
 | 
					            'border border-zinc-950/10 group-data-active:border-zinc-950/20 group-data-hover:border-zinc-950/20 dark:border-white/10 dark:group-data-active:border-white/20 dark:group-data-hover:border-white/20',
 | 
				
			||||||
 | 
					            // Background color
 | 
				
			||||||
 | 
					            'bg-transparent dark:bg-white/5',
 | 
				
			||||||
 | 
					            // Invalid state
 | 
				
			||||||
 | 
					            'group-data-invalid:border-red-500 group-data-hover:group-data-invalid:border-red-500 dark:group-data-invalid:border-red-600 dark:data-hover:group-data-invalid:border-red-600',
 | 
				
			||||||
 | 
					            // Disabled state
 | 
				
			||||||
 | 
					            'group-data-disabled:border-zinc-950/20 group-data-disabled:opacity-100 dark:group-data-disabled:border-white/15 dark:group-data-disabled:bg-white/[2.5%] dark:group-data-disabled:data-hover:border-white/15',
 | 
				
			||||||
 | 
					          ])}
 | 
				
			||||||
 | 
					        />
 | 
				
			||||||
 | 
					        <span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
 | 
				
			||||||
 | 
					          <svg
 | 
				
			||||||
 | 
					            className="size-5 stroke-zinc-500 group-data-disabled:stroke-zinc-600 sm:size-4 dark:stroke-zinc-400 forced-colors:stroke-[CanvasText]"
 | 
				
			||||||
 | 
					            viewBox="0 0 16 16"
 | 
				
			||||||
 | 
					            aria-hidden="true"
 | 
				
			||||||
 | 
					            fill="none"
 | 
				
			||||||
 | 
					          >
 | 
				
			||||||
 | 
					            <path d="M5.75 10.75L8 13L10.25 10.75" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" />
 | 
				
			||||||
 | 
					            <path d="M10.25 5.25L8 3L5.75 5.25" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" />
 | 
				
			||||||
 | 
					          </svg>
 | 
				
			||||||
 | 
					        </span>
 | 
				
			||||||
 | 
					      </Headless.ListboxButton>
 | 
				
			||||||
 | 
					      <Headless.ListboxOptions
 | 
				
			||||||
 | 
					        transition
 | 
				
			||||||
 | 
					        anchor="selection start"
 | 
				
			||||||
 | 
					        className={clsx(
 | 
				
			||||||
 | 
					          // Anchor positioning
 | 
				
			||||||
 | 
					          '[--anchor-offset:-1.625rem] [--anchor-padding:--spacing(4)] sm:[--anchor-offset:-1.375rem]',
 | 
				
			||||||
 | 
					          // Base styles
 | 
				
			||||||
 | 
					          'isolate w-max min-w-[calc(var(--button-width)+1.75rem)] scroll-py-1 rounded-xl p-1 select-none',
 | 
				
			||||||
 | 
					          // Invisible border that is only visible in `forced-colors` mode for accessibility purposes
 | 
				
			||||||
 | 
					          'outline outline-transparent focus:outline-hidden',
 | 
				
			||||||
 | 
					          // Handle scrolling when menu won't fit in viewport
 | 
				
			||||||
 | 
					          'overflow-y-scroll overscroll-contain',
 | 
				
			||||||
 | 
					          // Popover background
 | 
				
			||||||
 | 
					          'bg-white/75 backdrop-blur-xl dark:bg-zinc-800/75',
 | 
				
			||||||
 | 
					          // Shadows
 | 
				
			||||||
 | 
					          'shadow-lg ring-1 ring-zinc-950/10 dark:ring-white/10 dark:ring-inset',
 | 
				
			||||||
 | 
					          // Transitions
 | 
				
			||||||
 | 
					          'transition-opacity duration-100 ease-in data-closed:data-leave:opacity-0 data-transition:pointer-events-none'
 | 
				
			||||||
 | 
					        )}
 | 
				
			||||||
 | 
					      >
 | 
				
			||||||
 | 
					        {options}
 | 
				
			||||||
 | 
					      </Headless.ListboxOptions>
 | 
				
			||||||
 | 
					    </Headless.Listbox>
 | 
				
			||||||
 | 
					  )
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function ListboxOption<T>({
 | 
				
			||||||
 | 
					  children,
 | 
				
			||||||
 | 
					  className,
 | 
				
			||||||
 | 
					  ...props
 | 
				
			||||||
 | 
					}: { className?: string; children?: React.ReactNode } & Omit<
 | 
				
			||||||
 | 
					  Headless.ListboxOptionProps<'div', T>,
 | 
				
			||||||
 | 
					  'as' | 'className'
 | 
				
			||||||
 | 
					>) {
 | 
				
			||||||
 | 
					  let sharedClasses = clsx(
 | 
				
			||||||
 | 
					    // Base
 | 
				
			||||||
 | 
					    'flex min-w-0 items-center',
 | 
				
			||||||
 | 
					    // Icons
 | 
				
			||||||
 | 
					    '*:data-[slot=icon]:size-5 *:data-[slot=icon]:shrink-0 sm:*:data-[slot=icon]:size-4',
 | 
				
			||||||
 | 
					    '*:data-[slot=icon]:text-zinc-500 group-data-focus/option:*:data-[slot=icon]:text-white dark:*:data-[slot=icon]:text-zinc-400',
 | 
				
			||||||
 | 
					    'forced-colors:*:data-[slot=icon]:text-[CanvasText] forced-colors:group-data-focus/option:*:data-[slot=icon]:text-[Canvas]',
 | 
				
			||||||
 | 
					    // Avatars
 | 
				
			||||||
 | 
					    '*:data-[slot=avatar]:-mx-0.5 *:data-[slot=avatar]:size-6 sm:*:data-[slot=avatar]:size-5'
 | 
				
			||||||
 | 
					  )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <Headless.ListboxOption as={Fragment} {...props}>
 | 
				
			||||||
 | 
					      {({ selectedOption }) => {
 | 
				
			||||||
 | 
					        if (selectedOption) {
 | 
				
			||||||
 | 
					          return <div className={clsx(className, sharedClasses)}>{children}</div>
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return (
 | 
				
			||||||
 | 
					          <div
 | 
				
			||||||
 | 
					            className={clsx(
 | 
				
			||||||
 | 
					              // Basic layout
 | 
				
			||||||
 | 
					              'group/option grid cursor-default grid-cols-[--spacing(5)_1fr] items-baseline gap-x-2 rounded-lg py-2.5 pr-3.5 pl-2 sm:grid-cols-[--spacing(4)_1fr] sm:py-1.5 sm:pr-3 sm:pl-1.5',
 | 
				
			||||||
 | 
					              // Typography
 | 
				
			||||||
 | 
					              'text-base/6 text-zinc-950 sm:text-sm/6 dark:text-white forced-colors:text-[CanvasText]',
 | 
				
			||||||
 | 
					              // Focus
 | 
				
			||||||
 | 
					              'outline-hidden data-focus:bg-blue-500 data-focus:text-white',
 | 
				
			||||||
 | 
					              // Forced colors mode
 | 
				
			||||||
 | 
					              'forced-color-adjust-none forced-colors:data-focus:bg-[Highlight] forced-colors:data-focus:text-[HighlightText]',
 | 
				
			||||||
 | 
					              // Disabled
 | 
				
			||||||
 | 
					              'data-disabled:opacity-50'
 | 
				
			||||||
 | 
					            )}
 | 
				
			||||||
 | 
					          >
 | 
				
			||||||
 | 
					            <svg
 | 
				
			||||||
 | 
					              className="relative hidden size-5 self-center stroke-current group-data-selected/option:inline sm:size-4"
 | 
				
			||||||
 | 
					              viewBox="0 0 16 16"
 | 
				
			||||||
 | 
					              fill="none"
 | 
				
			||||||
 | 
					              aria-hidden="true"
 | 
				
			||||||
 | 
					            >
 | 
				
			||||||
 | 
					              <path d="M4 8.5l3 3L12 4" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" />
 | 
				
			||||||
 | 
					            </svg>
 | 
				
			||||||
 | 
					            <span className={clsx(className, sharedClasses, 'col-start-2')}>{children}</span>
 | 
				
			||||||
 | 
					          </div>
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					      }}
 | 
				
			||||||
 | 
					    </Headless.ListboxOption>
 | 
				
			||||||
 | 
					  )
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function ListboxLabel({ className, ...props }: React.ComponentPropsWithoutRef<'span'>) {
 | 
				
			||||||
 | 
					  return <span {...props} className={clsx(className, 'ml-2.5 truncate first:ml-0 sm:ml-2 sm:first:ml-0')} />
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function ListboxDescription({ className, children, ...props }: React.ComponentPropsWithoutRef<'span'>) {
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <span
 | 
				
			||||||
 | 
					      {...props}
 | 
				
			||||||
 | 
					      className={clsx(
 | 
				
			||||||
 | 
					        className,
 | 
				
			||||||
 | 
					        'flex flex-1 overflow-hidden text-zinc-500 group-data-focus/option:text-white before:w-2 before:min-w-0 before:shrink dark:text-zinc-400'
 | 
				
			||||||
 | 
					      )}
 | 
				
			||||||
 | 
					    >
 | 
				
			||||||
 | 
					      <span className="flex-1 truncate">{children}</span>
 | 
				
			||||||
 | 
					    </span>
 | 
				
			||||||
 | 
					  )
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										96
									
								
								src/components/navbar.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										96
									
								
								src/components/navbar.tsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,96 @@
 | 
				
			|||||||
 | 
					'use client'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import * as Headless from '@headlessui/react'
 | 
				
			||||||
 | 
					import clsx from 'clsx'
 | 
				
			||||||
 | 
					import { LayoutGroup, motion } from 'framer-motion'
 | 
				
			||||||
 | 
					import React, { forwardRef, useId } from 'react'
 | 
				
			||||||
 | 
					import { TouchTarget } from './button'
 | 
				
			||||||
 | 
					import { Link } from './link'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function Navbar({ className, ...props }: React.ComponentPropsWithoutRef<'nav'>) {
 | 
				
			||||||
 | 
					  return <nav {...props} className={clsx(className, 'flex flex-1 items-center gap-4 py-2.5')} />
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function NavbarDivider({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) {
 | 
				
			||||||
 | 
					  return <div aria-hidden="true" {...props} className={clsx(className, 'h-6 w-px bg-zinc-950/10 dark:bg-white/10')} />
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function NavbarSection({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) {
 | 
				
			||||||
 | 
					  let id = useId()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <LayoutGroup id={id}>
 | 
				
			||||||
 | 
					      <div {...props} className={clsx(className, 'flex items-center gap-3')} />
 | 
				
			||||||
 | 
					    </LayoutGroup>
 | 
				
			||||||
 | 
					  )
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function NavbarSpacer({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) {
 | 
				
			||||||
 | 
					  return <div aria-hidden="true" {...props} className={clsx(className, '-ml-4 flex-1')} />
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const NavbarItem = forwardRef(function NavbarItem(
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
 | 
					    current,
 | 
				
			||||||
 | 
					    className,
 | 
				
			||||||
 | 
					    children,
 | 
				
			||||||
 | 
					    ...props
 | 
				
			||||||
 | 
					  }: { current?: boolean; className?: string; children: React.ReactNode } & (
 | 
				
			||||||
 | 
					    | Omit<Headless.ButtonProps, 'as' | 'className'>
 | 
				
			||||||
 | 
					    | Omit<React.ComponentPropsWithoutRef<typeof Link>, 'className'>
 | 
				
			||||||
 | 
					  ),
 | 
				
			||||||
 | 
					  ref: React.ForwardedRef<HTMLAnchorElement | HTMLButtonElement>
 | 
				
			||||||
 | 
					) {
 | 
				
			||||||
 | 
					  let classes = clsx(
 | 
				
			||||||
 | 
					    // Base
 | 
				
			||||||
 | 
					    'relative flex min-w-0 items-center gap-3 rounded-lg p-2 text-left text-base/6 font-medium text-zinc-950 sm:text-sm/5',
 | 
				
			||||||
 | 
					    // Leading icon/icon-only
 | 
				
			||||||
 | 
					    '*:data-[slot=icon]:size-6 *:data-[slot=icon]:shrink-0 *:data-[slot=icon]:fill-zinc-500 sm:*:data-[slot=icon]:size-5',
 | 
				
			||||||
 | 
					    // Trailing icon (down chevron or similar)
 | 
				
			||||||
 | 
					    '*:not-nth-2:last:data-[slot=icon]:ml-auto *:not-nth-2:last:data-[slot=icon]:size-5 sm:*:not-nth-2:last:data-[slot=icon]:size-4',
 | 
				
			||||||
 | 
					    // Avatar
 | 
				
			||||||
 | 
					    '*:data-[slot=avatar]:-m-0.5 *:data-[slot=avatar]:size-7 *:data-[slot=avatar]:[--avatar-radius:var(--radius-md)] sm:*:data-[slot=avatar]:size-6',
 | 
				
			||||||
 | 
					    // Hover
 | 
				
			||||||
 | 
					    'data-hover:bg-zinc-950/5 data-hover:*:data-[slot=icon]:fill-zinc-950',
 | 
				
			||||||
 | 
					    // Active
 | 
				
			||||||
 | 
					    'data-active:bg-zinc-950/5 data-active:*:data-[slot=icon]:fill-zinc-950',
 | 
				
			||||||
 | 
					    // Dark mode
 | 
				
			||||||
 | 
					    'dark:text-white dark:*:data-[slot=icon]:fill-zinc-400',
 | 
				
			||||||
 | 
					    'dark:data-hover:bg-white/5 dark:data-hover:*:data-[slot=icon]:fill-white',
 | 
				
			||||||
 | 
					    'dark:data-active:bg-white/5 dark:data-active:*:data-[slot=icon]:fill-white'
 | 
				
			||||||
 | 
					  )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <span className={clsx(className, 'relative')}>
 | 
				
			||||||
 | 
					      {current && (
 | 
				
			||||||
 | 
					        <motion.span
 | 
				
			||||||
 | 
					          layoutId="current-indicator"
 | 
				
			||||||
 | 
					          className="absolute inset-x-2 -bottom-2.5 h-0.5 rounded-full bg-zinc-950 dark:bg-white"
 | 
				
			||||||
 | 
					        />
 | 
				
			||||||
 | 
					      )}
 | 
				
			||||||
 | 
					      {'href' in props ? (
 | 
				
			||||||
 | 
					        <Link
 | 
				
			||||||
 | 
					          {...props}
 | 
				
			||||||
 | 
					          className={classes}
 | 
				
			||||||
 | 
					          data-current={current ? 'true' : undefined}
 | 
				
			||||||
 | 
					          ref={ref as React.ForwardedRef<HTMLAnchorElement>}
 | 
				
			||||||
 | 
					        >
 | 
				
			||||||
 | 
					          <TouchTarget>{children}</TouchTarget>
 | 
				
			||||||
 | 
					        </Link>
 | 
				
			||||||
 | 
					      ) : (
 | 
				
			||||||
 | 
					        <Headless.Button
 | 
				
			||||||
 | 
					          {...props}
 | 
				
			||||||
 | 
					          className={clsx('cursor-default', classes)}
 | 
				
			||||||
 | 
					          data-current={current ? 'true' : undefined}
 | 
				
			||||||
 | 
					          ref={ref}
 | 
				
			||||||
 | 
					        >
 | 
				
			||||||
 | 
					          <TouchTarget>{children}</TouchTarget>
 | 
				
			||||||
 | 
					        </Headless.Button>
 | 
				
			||||||
 | 
					      )}
 | 
				
			||||||
 | 
					    </span>
 | 
				
			||||||
 | 
					  )
 | 
				
			||||||
 | 
					})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function NavbarLabel({ className, ...props }: React.ComponentPropsWithoutRef<'span'>) {
 | 
				
			||||||
 | 
					  return <span {...props} className={clsx(className, 'truncate')} />
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										101
									
								
								src/components/pagination.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										101
									
								
								src/components/pagination.tsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,101 @@
 | 
				
			|||||||
 | 
					import clsx from 'clsx'
 | 
				
			||||||
 | 
					import type React from 'react'
 | 
				
			||||||
 | 
					import { Button } from './button'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function Pagination({
 | 
				
			||||||
 | 
					  'aria-label': ariaLabel = 'Page navigation',
 | 
				
			||||||
 | 
					  className,
 | 
				
			||||||
 | 
					  ...props
 | 
				
			||||||
 | 
					}: React.ComponentPropsWithoutRef<'nav'>) {
 | 
				
			||||||
 | 
					  return <nav aria-label={ariaLabel} {...props} className={clsx(className, 'flex gap-x-2')} />
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function PaginationPrevious({
 | 
				
			||||||
 | 
					  href = null,
 | 
				
			||||||
 | 
					  className,
 | 
				
			||||||
 | 
					  children = 'Previous',
 | 
				
			||||||
 | 
					}: React.PropsWithChildren<{ href?: string | null; className?: string }>) {
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <span className={clsx(className, 'grow basis-0')}>
 | 
				
			||||||
 | 
					      <Button {...(href === null ? { disabled: true } : { href })} plain aria-label="Previous page">
 | 
				
			||||||
 | 
					        <svg className="stroke-current" data-slot="icon" viewBox="0 0 16 16" fill="none" aria-hidden="true">
 | 
				
			||||||
 | 
					          <path
 | 
				
			||||||
 | 
					            d="M2.75 8H13.25M2.75 8L5.25 5.5M2.75 8L5.25 10.5"
 | 
				
			||||||
 | 
					            strokeWidth={1.5}
 | 
				
			||||||
 | 
					            strokeLinecap="round"
 | 
				
			||||||
 | 
					            strokeLinejoin="round"
 | 
				
			||||||
 | 
					          />
 | 
				
			||||||
 | 
					        </svg>
 | 
				
			||||||
 | 
					        {children}
 | 
				
			||||||
 | 
					      </Button>
 | 
				
			||||||
 | 
					    </span>
 | 
				
			||||||
 | 
					  )
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function PaginationNext({
 | 
				
			||||||
 | 
					  href = null,
 | 
				
			||||||
 | 
					  className,
 | 
				
			||||||
 | 
					  children = 'Next',
 | 
				
			||||||
 | 
					}: React.PropsWithChildren<{ href?: string | null; className?: string }>) {
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <span className={clsx(className, 'flex grow basis-0 justify-end')}>
 | 
				
			||||||
 | 
					      <Button {...(href === null ? { disabled: true } : { href })} plain aria-label="Next page">
 | 
				
			||||||
 | 
					        {children}
 | 
				
			||||||
 | 
					        <svg className="stroke-current" data-slot="icon" viewBox="0 0 16 16" fill="none" aria-hidden="true">
 | 
				
			||||||
 | 
					          <path
 | 
				
			||||||
 | 
					            d="M13.25 8L2.75 8M13.25 8L10.75 10.5M13.25 8L10.75 5.5"
 | 
				
			||||||
 | 
					            strokeWidth={1.5}
 | 
				
			||||||
 | 
					            strokeLinecap="round"
 | 
				
			||||||
 | 
					            strokeLinejoin="round"
 | 
				
			||||||
 | 
					          />
 | 
				
			||||||
 | 
					        </svg>
 | 
				
			||||||
 | 
					      </Button>
 | 
				
			||||||
 | 
					    </span>
 | 
				
			||||||
 | 
					  )
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function PaginationList({ className, ...props }: React.ComponentPropsWithoutRef<'span'>) {
 | 
				
			||||||
 | 
					  return <span {...props} className={clsx(className, 'hidden items-baseline gap-x-2 sm:flex')} />
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function PaginationPage({
 | 
				
			||||||
 | 
					  href,
 | 
				
			||||||
 | 
					  className,
 | 
				
			||||||
 | 
					  current = false,
 | 
				
			||||||
 | 
					  children,
 | 
				
			||||||
 | 
					}: React.PropsWithChildren<{ href: string; className?: string; current?: boolean }>) {
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <Button
 | 
				
			||||||
 | 
					      href={href}
 | 
				
			||||||
 | 
					      plain
 | 
				
			||||||
 | 
					      aria-label={`Page ${children}`}
 | 
				
			||||||
 | 
					      aria-current={current ? 'page' : undefined}
 | 
				
			||||||
 | 
					      className={clsx(
 | 
				
			||||||
 | 
					        className,
 | 
				
			||||||
 | 
					        'min-w-[2.25rem] before:absolute before:-inset-px before:rounded-lg',
 | 
				
			||||||
 | 
					        current && 'before:bg-zinc-950/5 dark:before:bg-white/10'
 | 
				
			||||||
 | 
					      )}
 | 
				
			||||||
 | 
					    >
 | 
				
			||||||
 | 
					      <span className="-mx-0.5">{children}</span>
 | 
				
			||||||
 | 
					    </Button>
 | 
				
			||||||
 | 
					  )
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function PaginationGap({
 | 
				
			||||||
 | 
					  className,
 | 
				
			||||||
 | 
					  children = <>…</>,
 | 
				
			||||||
 | 
					  ...props
 | 
				
			||||||
 | 
					}: React.ComponentPropsWithoutRef<'span'>) {
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <span
 | 
				
			||||||
 | 
					      aria-hidden="true"
 | 
				
			||||||
 | 
					      {...props}
 | 
				
			||||||
 | 
					      className={clsx(
 | 
				
			||||||
 | 
					        className,
 | 
				
			||||||
 | 
					        'w-[2.25rem] text-center text-sm/6 font-semibold text-zinc-950 select-none dark:text-white'
 | 
				
			||||||
 | 
					      )}
 | 
				
			||||||
 | 
					    >
 | 
				
			||||||
 | 
					      {children}
 | 
				
			||||||
 | 
					    </span>
 | 
				
			||||||
 | 
					  )
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										142
									
								
								src/components/radio.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										142
									
								
								src/components/radio.tsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,142 @@
 | 
				
			|||||||
 | 
					import * as Headless from '@headlessui/react'
 | 
				
			||||||
 | 
					import clsx from 'clsx'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function RadioGroup({
 | 
				
			||||||
 | 
					  className,
 | 
				
			||||||
 | 
					  ...props
 | 
				
			||||||
 | 
					}: { className?: string } & Omit<Headless.RadioGroupProps, 'as' | 'className'>) {
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <Headless.RadioGroup
 | 
				
			||||||
 | 
					      data-slot="control"
 | 
				
			||||||
 | 
					      {...props}
 | 
				
			||||||
 | 
					      className={clsx(
 | 
				
			||||||
 | 
					        className,
 | 
				
			||||||
 | 
					        // Basic groups
 | 
				
			||||||
 | 
					        'space-y-3 **:data-[slot=label]:font-normal',
 | 
				
			||||||
 | 
					        // With descriptions
 | 
				
			||||||
 | 
					        'has-data-[slot=description]:space-y-6 has-data-[slot=description]:**:data-[slot=label]:font-medium'
 | 
				
			||||||
 | 
					      )}
 | 
				
			||||||
 | 
					    />
 | 
				
			||||||
 | 
					  )
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function RadioField({
 | 
				
			||||||
 | 
					  className,
 | 
				
			||||||
 | 
					  ...props
 | 
				
			||||||
 | 
					}: { className?: string } & Omit<Headless.FieldProps, 'as' | 'className'>) {
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <Headless.Field
 | 
				
			||||||
 | 
					      data-slot="field"
 | 
				
			||||||
 | 
					      {...props}
 | 
				
			||||||
 | 
					      className={clsx(
 | 
				
			||||||
 | 
					        className,
 | 
				
			||||||
 | 
					        // Base layout
 | 
				
			||||||
 | 
					        'grid grid-cols-[1.125rem_1fr] gap-x-4 gap-y-1 sm:grid-cols-[1rem_1fr]',
 | 
				
			||||||
 | 
					        // Control layout
 | 
				
			||||||
 | 
					        '*:data-[slot=control]:col-start-1 *:data-[slot=control]:row-start-1 *:data-[slot=control]:mt-0.75 sm:*:data-[slot=control]:mt-1',
 | 
				
			||||||
 | 
					        // Label layout
 | 
				
			||||||
 | 
					        '*:data-[slot=label]:col-start-2 *:data-[slot=label]:row-start-1',
 | 
				
			||||||
 | 
					        // Description layout
 | 
				
			||||||
 | 
					        '*:data-[slot=description]:col-start-2 *:data-[slot=description]:row-start-2',
 | 
				
			||||||
 | 
					        // With description
 | 
				
			||||||
 | 
					        'has-data-[slot=description]:**:data-[slot=label]:font-medium'
 | 
				
			||||||
 | 
					      )}
 | 
				
			||||||
 | 
					    />
 | 
				
			||||||
 | 
					  )
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const base = [
 | 
				
			||||||
 | 
					  // Basic layout
 | 
				
			||||||
 | 
					  'relative isolate flex size-[1.1875rem] shrink-0 rounded-full sm:size-[1.0625rem]',
 | 
				
			||||||
 | 
					  // Background color + shadow applied to inset pseudo element, so shadow blends with border in light mode
 | 
				
			||||||
 | 
					  'before:absolute before:inset-0 before:-z-10 before:rounded-full before:bg-white before:shadow-sm',
 | 
				
			||||||
 | 
					  // Background color when checked
 | 
				
			||||||
 | 
					  'group-data-checked:before:bg-(--radio-checked-bg)',
 | 
				
			||||||
 | 
					  // Background color is moved to control and shadow is removed in dark mode so hide `before` pseudo
 | 
				
			||||||
 | 
					  'dark:before:hidden',
 | 
				
			||||||
 | 
					  // Background color applied to control in dark mode
 | 
				
			||||||
 | 
					  'dark:bg-white/5 dark:group-data-checked:bg-(--radio-checked-bg)',
 | 
				
			||||||
 | 
					  // Border
 | 
				
			||||||
 | 
					  'border border-zinc-950/15 group-data-checked:border-transparent group-data-hover:group-data-checked:border-transparent group-data-hover:border-zinc-950/30 group-data-checked:bg-(--radio-checked-border)',
 | 
				
			||||||
 | 
					  'dark:border-white/15 dark:group-data-checked:border-white/5 dark:group-data-hover:group-data-checked:border-white/5 dark:group-data-hover:border-white/30',
 | 
				
			||||||
 | 
					  // Inner highlight shadow
 | 
				
			||||||
 | 
					  'after:absolute after:inset-0 after:rounded-full after:shadow-[inset_0_1px_--theme(--color-white/15%)]',
 | 
				
			||||||
 | 
					  'dark:after:-inset-px dark:after:hidden dark:after:rounded-full dark:group-data-checked:after:block',
 | 
				
			||||||
 | 
					  // Indicator color (light mode)
 | 
				
			||||||
 | 
					  '[--radio-indicator:transparent] group-data-checked:[--radio-indicator:var(--radio-checked-indicator)] group-data-hover:group-data-checked:[--radio-indicator:var(--radio-checked-indicator)] group-data-hover:[--radio-indicator:var(--color-zinc-900)]/10',
 | 
				
			||||||
 | 
					  // Indicator color (dark mode)
 | 
				
			||||||
 | 
					  'dark:group-data-hover:group-data-checked:[--radio-indicator:var(--radio-checked-indicator)] dark:group-data-hover:[--radio-indicator:var(--color-zinc-700)]',
 | 
				
			||||||
 | 
					  // Focus ring
 | 
				
			||||||
 | 
					  'group-data-focus:outline group-data-focus:outline-2 group-data-focus:outline-offset-2 group-data-focus:outline-blue-500',
 | 
				
			||||||
 | 
					  // Disabled state
 | 
				
			||||||
 | 
					  'group-data-disabled:opacity-50',
 | 
				
			||||||
 | 
					  'group-data-disabled:border-zinc-950/25 group-data-disabled:bg-zinc-950/5 group-data-disabled:[--radio-checked-indicator:var(--color-zinc-950)]/50 group-data-disabled:before:bg-transparent',
 | 
				
			||||||
 | 
					  'dark:group-data-disabled:border-white/20 dark:group-data-disabled:bg-white/[2.5%] dark:group-data-disabled:[--radio-checked-indicator:var(--color-white)]/50 dark:group-data-checked:group-data-disabled:after:hidden',
 | 
				
			||||||
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const colors = {
 | 
				
			||||||
 | 
					  'dark/zinc': [
 | 
				
			||||||
 | 
					    '[--radio-checked-bg:var(--color-zinc-900)] [--radio-checked-border:var(--color-zinc-950)]/90 [--radio-checked-indicator:var(--color-white)]',
 | 
				
			||||||
 | 
					    'dark:[--radio-checked-bg:var(--color-zinc-600)]',
 | 
				
			||||||
 | 
					  ],
 | 
				
			||||||
 | 
					  'dark/white': [
 | 
				
			||||||
 | 
					    '[--radio-checked-bg:var(--color-zinc-900)] [--radio-checked-border:var(--color-zinc-950)]/90 [--radio-checked-indicator:var(--color-white)]',
 | 
				
			||||||
 | 
					    'dark:[--radio-checked-bg:var(--color-white)] dark:[--radio-checked-border:var(--color-zinc-950)]/15 dark:[--radio-checked-indicator:var(--color-zinc-900)]',
 | 
				
			||||||
 | 
					  ],
 | 
				
			||||||
 | 
					  white:
 | 
				
			||||||
 | 
					    '[--radio-checked-bg:var(--color-white)] [--radio-checked-border:var(--color-zinc-950)]/15 [--radio-checked-indicator:var(--color-zinc-900)]',
 | 
				
			||||||
 | 
					  dark: '[--radio-checked-bg:var(--color-zinc-900)] [--radio-checked-border:var(--color-zinc-950)]/90 [--radio-checked-indicator:var(--color-white)]',
 | 
				
			||||||
 | 
					  zinc: '[--radio-checked-indicator:var(--color-white)] [--radio-checked-bg:var(--color-zinc-600)] [--radio-checked-border:var(--color-zinc-700)]/90',
 | 
				
			||||||
 | 
					  red: '[--radio-checked-indicator:var(--color-white)] [--radio-checked-bg:var(--color-red-600)] [--radio-checked-border:var(--color-red-700)]/90',
 | 
				
			||||||
 | 
					  orange:
 | 
				
			||||||
 | 
					    '[--radio-checked-indicator:var(--color-white)] [--radio-checked-bg:var(--color-orange-500)] [--radio-checked-border:var(--color-orange-600)]/90',
 | 
				
			||||||
 | 
					  amber:
 | 
				
			||||||
 | 
					    '[--radio-checked-bg:var(--color-amber-400)] [--radio-checked-border:var(--color-amber-500)]/80 [--radio-checked-indicator:var(--color-amber-950)]',
 | 
				
			||||||
 | 
					  yellow:
 | 
				
			||||||
 | 
					    '[--radio-checked-bg:var(--color-yellow-300)] [--radio-checked-border:var(--color-yellow-400)]/80 [--radio-checked-indicator:var(--color-yellow-950)]',
 | 
				
			||||||
 | 
					  lime: '[--radio-checked-bg:var(--color-lime-300)] [--radio-checked-border:var(--color-lime-400)]/80 [--radio-checked-indicator:var(--color-lime-950)]',
 | 
				
			||||||
 | 
					  green:
 | 
				
			||||||
 | 
					    '[--radio-checked-indicator:var(--color-white)] [--radio-checked-bg:var(--color-green-600)] [--radio-checked-border:var(--color-green-700)]/90',
 | 
				
			||||||
 | 
					  emerald:
 | 
				
			||||||
 | 
					    '[--radio-checked-indicator:var(--color-white)] [--radio-checked-bg:var(--color-emerald-600)] [--radio-checked-border:var(--color-emerald-700)]/90',
 | 
				
			||||||
 | 
					  teal: '[--radio-checked-indicator:var(--color-white)] [--radio-checked-bg:var(--color-teal-600)] [--radio-checked-border:var(--color-teal-700)]/90',
 | 
				
			||||||
 | 
					  cyan: '[--radio-checked-bg:var(--color-cyan-300)] [--radio-checked-border:var(--color-cyan-400)]/80 [--radio-checked-indicator:var(--color-cyan-950)]',
 | 
				
			||||||
 | 
					  sky: '[--radio-checked-indicator:var(--color-white)] [--radio-checked-bg:var(--color-sky-500)] [--radio-checked-border:var(--color-sky-600)]/80',
 | 
				
			||||||
 | 
					  blue: '[--radio-checked-indicator:var(--color-white)] [--radio-checked-bg:var(--color-blue-600)] [--radio-checked-border:var(--color-blue-700)]/90',
 | 
				
			||||||
 | 
					  indigo:
 | 
				
			||||||
 | 
					    '[--radio-checked-indicator:var(--color-white)] [--radio-checked-bg:var(--color-indigo-500)] [--radio-checked-border:var(--color-indigo-600)]/90',
 | 
				
			||||||
 | 
					  violet:
 | 
				
			||||||
 | 
					    '[--radio-checked-indicator:var(--color-white)] [--radio-checked-bg:var(--color-violet-500)] [--radio-checked-border:var(--color-violet-600)]/90',
 | 
				
			||||||
 | 
					  purple:
 | 
				
			||||||
 | 
					    '[--radio-checked-indicator:var(--color-white)] [--radio-checked-bg:var(--color-purple-500)] [--radio-checked-border:var(--color-purple-600)]/90',
 | 
				
			||||||
 | 
					  fuchsia:
 | 
				
			||||||
 | 
					    '[--radio-checked-indicator:var(--color-white)] [--radio-checked-bg:var(--color-fuchsia-500)] [--radio-checked-border:var(--color-fuchsia-600)]/90',
 | 
				
			||||||
 | 
					  pink: '[--radio-checked-indicator:var(--color-white)] [--radio-checked-bg:var(--color-pink-500)] [--radio-checked-border:var(--color-pink-600)]/90',
 | 
				
			||||||
 | 
					  rose: '[--radio-checked-indicator:var(--color-white)] [--radio-checked-bg:var(--color-rose-500)] [--radio-checked-border:var(--color-rose-600)]/90',
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					type Color = keyof typeof colors
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function Radio({
 | 
				
			||||||
 | 
					  color = 'dark/zinc',
 | 
				
			||||||
 | 
					  className,
 | 
				
			||||||
 | 
					  ...props
 | 
				
			||||||
 | 
					}: { color?: Color; className?: string } & Omit<Headless.RadioProps, 'as' | 'className' | 'children'>) {
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <Headless.Radio
 | 
				
			||||||
 | 
					      data-slot="control"
 | 
				
			||||||
 | 
					      {...props}
 | 
				
			||||||
 | 
					      className={clsx(className, 'group inline-flex focus:outline-hidden')}
 | 
				
			||||||
 | 
					    >
 | 
				
			||||||
 | 
					      <span className={clsx([base, colors[color]])}>
 | 
				
			||||||
 | 
					        <span
 | 
				
			||||||
 | 
					          className={clsx(
 | 
				
			||||||
 | 
					            'size-full rounded-full border-[4.5px] border-transparent bg-(--radio-indicator) bg-clip-padding',
 | 
				
			||||||
 | 
					            // Forced colors mode
 | 
				
			||||||
 | 
					            'forced-colors:border-[Canvas] forced-colors:group-data-checked:border-[Highlight]'
 | 
				
			||||||
 | 
					          )}
 | 
				
			||||||
 | 
					        />
 | 
				
			||||||
 | 
					      </span>
 | 
				
			||||||
 | 
					    </Headless.Radio>
 | 
				
			||||||
 | 
					  )
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										68
									
								
								src/components/select.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										68
									
								
								src/components/select.tsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,68 @@
 | 
				
			|||||||
 | 
					import * as Headless from '@headlessui/react'
 | 
				
			||||||
 | 
					import clsx from 'clsx'
 | 
				
			||||||
 | 
					import React, { forwardRef } from 'react'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const Select = forwardRef(function Select(
 | 
				
			||||||
 | 
					  { className, multiple, ...props }: { className?: string } & Omit<Headless.SelectProps, 'as' | 'className'>,
 | 
				
			||||||
 | 
					  ref: React.ForwardedRef<HTMLSelectElement>
 | 
				
			||||||
 | 
					) {
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <span
 | 
				
			||||||
 | 
					      data-slot="control"
 | 
				
			||||||
 | 
					      className={clsx([
 | 
				
			||||||
 | 
					        className,
 | 
				
			||||||
 | 
					        // Basic layout
 | 
				
			||||||
 | 
					        'group relative block w-full',
 | 
				
			||||||
 | 
					        // Background color + shadow applied to inset pseudo element, so shadow blends with border in light mode
 | 
				
			||||||
 | 
					        'before:absolute before:inset-px before:rounded-[calc(var(--radius-lg)-1px)] before:bg-white before:shadow-sm',
 | 
				
			||||||
 | 
					        // Background color is moved to control and shadow is removed in dark mode so hide `before` pseudo
 | 
				
			||||||
 | 
					        'dark:before:hidden',
 | 
				
			||||||
 | 
					        // Focus ring
 | 
				
			||||||
 | 
					        'after:pointer-events-none after:absolute after:inset-0 after:rounded-lg after:ring-transparent after:ring-inset has-data-focus:after:ring-2 has-data-focus:after:ring-blue-500',
 | 
				
			||||||
 | 
					        // Disabled state
 | 
				
			||||||
 | 
					        'has-data-disabled:opacity-50 has-data-disabled:before:bg-zinc-950/5 has-data-disabled:before:shadow-none',
 | 
				
			||||||
 | 
					      ])}
 | 
				
			||||||
 | 
					    >
 | 
				
			||||||
 | 
					      <Headless.Select
 | 
				
			||||||
 | 
					        ref={ref}
 | 
				
			||||||
 | 
					        multiple={multiple}
 | 
				
			||||||
 | 
					        {...props}
 | 
				
			||||||
 | 
					        className={clsx([
 | 
				
			||||||
 | 
					          // Basic layout
 | 
				
			||||||
 | 
					          'relative block w-full appearance-none rounded-lg py-[calc(--spacing(2.5)-1px)] sm:py-[calc(--spacing(1.5)-1px)]',
 | 
				
			||||||
 | 
					          // Horizontal padding
 | 
				
			||||||
 | 
					          multiple
 | 
				
			||||||
 | 
					            ? 'px-[calc(--spacing(3.5)-1px)] sm:px-[calc(--spacing(3)-1px)]'
 | 
				
			||||||
 | 
					            : 'pr-[calc(--spacing(10)-1px)] pl-[calc(--spacing(3.5)-1px)] sm:pr-[calc(--spacing(9)-1px)] sm:pl-[calc(--spacing(3)-1px)]',
 | 
				
			||||||
 | 
					          // Options (multi-select)
 | 
				
			||||||
 | 
					          '[&_optgroup]:font-semibold',
 | 
				
			||||||
 | 
					          // Typography
 | 
				
			||||||
 | 
					          'text-base/6 text-zinc-950 placeholder:text-zinc-500 sm:text-sm/6 dark:text-white dark:*:text-white',
 | 
				
			||||||
 | 
					          // Border
 | 
				
			||||||
 | 
					          'border border-zinc-950/10 data-hover:border-zinc-950/20 dark:border-white/10 dark:data-hover:border-white/20',
 | 
				
			||||||
 | 
					          // Background color
 | 
				
			||||||
 | 
					          'bg-transparent dark:bg-white/5 dark:*:bg-zinc-800',
 | 
				
			||||||
 | 
					          // Hide default focus styles
 | 
				
			||||||
 | 
					          'focus:outline-hidden',
 | 
				
			||||||
 | 
					          // Invalid state
 | 
				
			||||||
 | 
					          'data-invalid:border-red-500 data-invalid:data-hover:border-red-500 dark:data-invalid:border-red-600 dark:data-invalid:data-hover:border-red-600',
 | 
				
			||||||
 | 
					          // Disabled state
 | 
				
			||||||
 | 
					          'data-disabled:border-zinc-950/20 data-disabled:opacity-100 dark:data-disabled:border-white/15 dark:data-disabled:bg-white/[2.5%] dark:data-hover:data-disabled:border-white/15',
 | 
				
			||||||
 | 
					        ])}
 | 
				
			||||||
 | 
					      />
 | 
				
			||||||
 | 
					      {!multiple && (
 | 
				
			||||||
 | 
					        <span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
 | 
				
			||||||
 | 
					          <svg
 | 
				
			||||||
 | 
					            className="size-5 stroke-zinc-500 group-has-data-disabled:stroke-zinc-600 sm:size-4 dark:stroke-zinc-400 forced-colors:stroke-[CanvasText]"
 | 
				
			||||||
 | 
					            viewBox="0 0 16 16"
 | 
				
			||||||
 | 
					            aria-hidden="true"
 | 
				
			||||||
 | 
					            fill="none"
 | 
				
			||||||
 | 
					          >
 | 
				
			||||||
 | 
					            <path d="M5.75 10.75L8 13L10.25 10.75" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" />
 | 
				
			||||||
 | 
					            <path d="M10.25 5.25L8 3L5.75 5.25" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" />
 | 
				
			||||||
 | 
					          </svg>
 | 
				
			||||||
 | 
					        </span>
 | 
				
			||||||
 | 
					      )}
 | 
				
			||||||
 | 
					    </span>
 | 
				
			||||||
 | 
					  )
 | 
				
			||||||
 | 
					})
 | 
				
			||||||
							
								
								
									
										82
									
								
								src/components/sidebar-layout.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										82
									
								
								src/components/sidebar-layout.tsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,82 @@
 | 
				
			|||||||
 | 
					'use client'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import * as Headless from '@headlessui/react'
 | 
				
			||||||
 | 
					import React, { useState } from 'react'
 | 
				
			||||||
 | 
					import { NavbarItem } from './navbar'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					function OpenMenuIcon() {
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <svg data-slot="icon" viewBox="0 0 20 20" aria-hidden="true">
 | 
				
			||||||
 | 
					      <path d="M2 6.75C2 6.33579 2.33579 6 2.75 6H17.25C17.6642 6 18 6.33579 18 6.75C18 7.16421 17.6642 7.5 17.25 7.5H2.75C2.33579 7.5 2 7.16421 2 6.75ZM2 13.25C2 12.8358 2.33579 12.5 2.75 12.5H17.25C17.6642 12.5 18 12.8358 18 13.25C18 13.6642 17.6642 14 17.25 14H2.75C2.33579 14 2 13.6642 2 13.25Z" />
 | 
				
			||||||
 | 
					    </svg>
 | 
				
			||||||
 | 
					  )
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					function CloseMenuIcon() {
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <svg data-slot="icon" viewBox="0 0 20 20" aria-hidden="true">
 | 
				
			||||||
 | 
					      <path d="M6.28 5.22a.75.75 0 0 0-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 1 0 1.06 1.06L10 11.06l3.72 3.72a.75.75 0 1 0 1.06-1.06L11.06 10l3.72-3.72a.75.75 0 0 0-1.06-1.06L10 8.94 6.28 5.22Z" />
 | 
				
			||||||
 | 
					    </svg>
 | 
				
			||||||
 | 
					  )
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					function MobileSidebar({ open, close, children }: React.PropsWithChildren<{ open: boolean; close: () => void }>) {
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <Headless.Dialog open={open} onClose={close} className="lg:hidden">
 | 
				
			||||||
 | 
					      <Headless.DialogBackdrop
 | 
				
			||||||
 | 
					        transition
 | 
				
			||||||
 | 
					        className="fixed inset-0 bg-black/30 transition data-closed:opacity-0 data-enter:duration-300 data-enter:ease-out data-leave:duration-200 data-leave:ease-in"
 | 
				
			||||||
 | 
					      />
 | 
				
			||||||
 | 
					      <Headless.DialogPanel
 | 
				
			||||||
 | 
					        transition
 | 
				
			||||||
 | 
					        className="fixed inset-y-0 w-full max-w-80 p-2 transition duration-300 ease-in-out data-closed:-translate-x-full"
 | 
				
			||||||
 | 
					      >
 | 
				
			||||||
 | 
					        <div className="flex h-full flex-col rounded-lg bg-white shadow-xs ring-1 ring-zinc-950/5 dark:bg-zinc-900 dark:ring-white/10">
 | 
				
			||||||
 | 
					          <div className="-mb-3 px-4 pt-3">
 | 
				
			||||||
 | 
					            <Headless.CloseButton as={NavbarItem} aria-label="Close navigation">
 | 
				
			||||||
 | 
					              <CloseMenuIcon />
 | 
				
			||||||
 | 
					            </Headless.CloseButton>
 | 
				
			||||||
 | 
					          </div>
 | 
				
			||||||
 | 
					          {children}
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					      </Headless.DialogPanel>
 | 
				
			||||||
 | 
					    </Headless.Dialog>
 | 
				
			||||||
 | 
					  )
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function SidebarLayout({
 | 
				
			||||||
 | 
					  navbar,
 | 
				
			||||||
 | 
					  sidebar,
 | 
				
			||||||
 | 
					  children,
 | 
				
			||||||
 | 
					}: React.PropsWithChildren<{ navbar: React.ReactNode; sidebar: React.ReactNode }>) {
 | 
				
			||||||
 | 
					  let [showSidebar, setShowSidebar] = useState(false)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <div className="relative isolate flex min-h-svh w-full bg-white max-lg:flex-col lg:bg-zinc-100 dark:bg-zinc-900 dark:lg:bg-zinc-950">
 | 
				
			||||||
 | 
					      {/* Sidebar on desktop */}
 | 
				
			||||||
 | 
					      <div className="fixed inset-y-0 left-0 w-64 max-lg:hidden">{sidebar}</div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      {/* Sidebar on mobile */}
 | 
				
			||||||
 | 
					      <MobileSidebar open={showSidebar} close={() => setShowSidebar(false)}>
 | 
				
			||||||
 | 
					        {sidebar}
 | 
				
			||||||
 | 
					      </MobileSidebar>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      {/* Navbar on mobile */}
 | 
				
			||||||
 | 
					      <header className="flex items-center px-4 lg:hidden">
 | 
				
			||||||
 | 
					        <div className="py-2.5">
 | 
				
			||||||
 | 
					          <NavbarItem onClick={() => setShowSidebar(true)} aria-label="Open navigation">
 | 
				
			||||||
 | 
					            <OpenMenuIcon />
 | 
				
			||||||
 | 
					          </NavbarItem>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					        <div className="min-w-0 flex-1">{navbar}</div>
 | 
				
			||||||
 | 
					      </header>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      {/* Content */}
 | 
				
			||||||
 | 
					      <main className="flex flex-1 flex-col pb-2 lg:min-w-0 lg:pt-2 lg:pr-2 lg:pl-64">
 | 
				
			||||||
 | 
					        <div className="grow p-6 lg:rounded-lg lg:bg-white lg:p-10 lg:shadow-xs lg:ring-1 lg:ring-zinc-950/5 dark:lg:bg-zinc-900 dark:lg:ring-white/10">
 | 
				
			||||||
 | 
					          <div className="mx-auto max-w-6xl">{children}</div>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					      </main>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					  )
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										142
									
								
								src/components/sidebar.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										142
									
								
								src/components/sidebar.tsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,142 @@
 | 
				
			|||||||
 | 
					'use client'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import * as Headless from '@headlessui/react'
 | 
				
			||||||
 | 
					import clsx from 'clsx'
 | 
				
			||||||
 | 
					import { LayoutGroup, motion } from 'framer-motion'
 | 
				
			||||||
 | 
					import React, { forwardRef, useId } from 'react'
 | 
				
			||||||
 | 
					import { TouchTarget } from './button'
 | 
				
			||||||
 | 
					import { Link } from './link'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function Sidebar({ className, ...props }: React.ComponentPropsWithoutRef<'nav'>) {
 | 
				
			||||||
 | 
					  return <nav {...props} className={clsx(className, 'flex h-full min-h-0 flex-col')} />
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function SidebarHeader({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) {
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <div
 | 
				
			||||||
 | 
					      {...props}
 | 
				
			||||||
 | 
					      className={clsx(
 | 
				
			||||||
 | 
					        className,
 | 
				
			||||||
 | 
					        'flex flex-col border-b border-zinc-950/5 p-4 dark:border-white/5 [&>[data-slot=section]+[data-slot=section]]:mt-2.5'
 | 
				
			||||||
 | 
					      )}
 | 
				
			||||||
 | 
					    />
 | 
				
			||||||
 | 
					  )
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function SidebarBody({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) {
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <div
 | 
				
			||||||
 | 
					      {...props}
 | 
				
			||||||
 | 
					      className={clsx(
 | 
				
			||||||
 | 
					        className,
 | 
				
			||||||
 | 
					        'flex flex-1 flex-col overflow-y-auto p-4 [&>[data-slot=section]+[data-slot=section]]:mt-8'
 | 
				
			||||||
 | 
					      )}
 | 
				
			||||||
 | 
					    />
 | 
				
			||||||
 | 
					  )
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function SidebarFooter({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) {
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <div
 | 
				
			||||||
 | 
					      {...props}
 | 
				
			||||||
 | 
					      className={clsx(
 | 
				
			||||||
 | 
					        className,
 | 
				
			||||||
 | 
					        'flex flex-col border-t border-zinc-950/5 p-4 dark:border-white/5 [&>[data-slot=section]+[data-slot=section]]:mt-2.5'
 | 
				
			||||||
 | 
					      )}
 | 
				
			||||||
 | 
					    />
 | 
				
			||||||
 | 
					  )
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function SidebarSection({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) {
 | 
				
			||||||
 | 
					  let id = useId()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <LayoutGroup id={id}>
 | 
				
			||||||
 | 
					      <div {...props} data-slot="section" className={clsx(className, 'flex flex-col gap-0.5')} />
 | 
				
			||||||
 | 
					    </LayoutGroup>
 | 
				
			||||||
 | 
					  )
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function SidebarDivider({ className, ...props }: React.ComponentPropsWithoutRef<'hr'>) {
 | 
				
			||||||
 | 
					  return <hr {...props} className={clsx(className, 'my-4 border-t border-zinc-950/5 lg:-mx-4 dark:border-white/5')} />
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function SidebarSpacer({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) {
 | 
				
			||||||
 | 
					  return <div aria-hidden="true" {...props} className={clsx(className, 'mt-8 flex-1')} />
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function SidebarHeading({ className, ...props }: React.ComponentPropsWithoutRef<'h3'>) {
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <h3 {...props} className={clsx(className, 'mb-1 px-2 text-xs/6 font-medium text-zinc-500 dark:text-zinc-400')} />
 | 
				
			||||||
 | 
					  )
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const SidebarItem = forwardRef(function SidebarItem(
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
 | 
					    current,
 | 
				
			||||||
 | 
					    className,
 | 
				
			||||||
 | 
					    children,
 | 
				
			||||||
 | 
					    ...props
 | 
				
			||||||
 | 
					  }: { current?: boolean; className?: string; children: React.ReactNode } & (
 | 
				
			||||||
 | 
					    | Omit<Headless.ButtonProps, 'as' | 'className'>
 | 
				
			||||||
 | 
					    | Omit<Headless.ButtonProps<typeof Link>, 'as' | 'className'>
 | 
				
			||||||
 | 
					  ),
 | 
				
			||||||
 | 
					  ref: React.ForwardedRef<HTMLAnchorElement | HTMLButtonElement>
 | 
				
			||||||
 | 
					) {
 | 
				
			||||||
 | 
					  let classes = clsx(
 | 
				
			||||||
 | 
					    // Base
 | 
				
			||||||
 | 
					    'flex w-full items-center gap-3 rounded-lg px-2 py-2.5 text-left text-base/6 font-medium text-zinc-950 sm:py-2 sm:text-sm/5',
 | 
				
			||||||
 | 
					    // Leading icon/icon-only
 | 
				
			||||||
 | 
					    '*:data-[slot=icon]:size-6 *:data-[slot=icon]:shrink-0 *:data-[slot=icon]:fill-zinc-500 sm:*:data-[slot=icon]:size-5',
 | 
				
			||||||
 | 
					    // Trailing icon (down chevron or similar)
 | 
				
			||||||
 | 
					    '*:last:data-[slot=icon]:ml-auto *:last:data-[slot=icon]:size-5 sm:*:last:data-[slot=icon]:size-4',
 | 
				
			||||||
 | 
					    // Avatar
 | 
				
			||||||
 | 
					    '*:data-[slot=avatar]:-m-0.5 *:data-[slot=avatar]:size-7 sm:*:data-[slot=avatar]:size-6',
 | 
				
			||||||
 | 
					    // Hover
 | 
				
			||||||
 | 
					    'data-hover:bg-zinc-950/5 data-hover:*:data-[slot=icon]:fill-zinc-950',
 | 
				
			||||||
 | 
					    // Active
 | 
				
			||||||
 | 
					    'data-active:bg-zinc-950/5 data-active:*:data-[slot=icon]:fill-zinc-950',
 | 
				
			||||||
 | 
					    // Current
 | 
				
			||||||
 | 
					    'data-current:*:data-[slot=icon]:fill-zinc-950',
 | 
				
			||||||
 | 
					    // Dark mode
 | 
				
			||||||
 | 
					    'dark:text-white dark:*:data-[slot=icon]:fill-zinc-400',
 | 
				
			||||||
 | 
					    'dark:data-hover:bg-white/5 dark:data-hover:*:data-[slot=icon]:fill-white',
 | 
				
			||||||
 | 
					    'dark:data-active:bg-white/5 dark:data-active:*:data-[slot=icon]:fill-white',
 | 
				
			||||||
 | 
					    'dark:data-current:*:data-[slot=icon]:fill-white'
 | 
				
			||||||
 | 
					  )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <span className={clsx(className, 'relative')}>
 | 
				
			||||||
 | 
					      {current && (
 | 
				
			||||||
 | 
					        <motion.span
 | 
				
			||||||
 | 
					          layoutId="current-indicator"
 | 
				
			||||||
 | 
					          className="absolute inset-y-2 -left-4 w-0.5 rounded-full bg-zinc-950 dark:bg-white"
 | 
				
			||||||
 | 
					        />
 | 
				
			||||||
 | 
					      )}
 | 
				
			||||||
 | 
					      {'href' in props ? (
 | 
				
			||||||
 | 
					        <Headless.CloseButton
 | 
				
			||||||
 | 
					          as={Link}
 | 
				
			||||||
 | 
					          {...props}
 | 
				
			||||||
 | 
					          className={classes}
 | 
				
			||||||
 | 
					          data-current={current ? 'true' : undefined}
 | 
				
			||||||
 | 
					          ref={ref}
 | 
				
			||||||
 | 
					        >
 | 
				
			||||||
 | 
					          <TouchTarget>{children}</TouchTarget>
 | 
				
			||||||
 | 
					        </Headless.CloseButton>
 | 
				
			||||||
 | 
					      ) : (
 | 
				
			||||||
 | 
					        <Headless.Button
 | 
				
			||||||
 | 
					          {...props}
 | 
				
			||||||
 | 
					          className={clsx('cursor-default', classes)}
 | 
				
			||||||
 | 
					          data-current={current ? 'true' : undefined}
 | 
				
			||||||
 | 
					          ref={ref}
 | 
				
			||||||
 | 
					        >
 | 
				
			||||||
 | 
					          <TouchTarget>{children}</TouchTarget>
 | 
				
			||||||
 | 
					        </Headless.Button>
 | 
				
			||||||
 | 
					      )}
 | 
				
			||||||
 | 
					    </span>
 | 
				
			||||||
 | 
					  )
 | 
				
			||||||
 | 
					})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function SidebarLabel({ className, ...props }: React.ComponentPropsWithoutRef<'span'>) {
 | 
				
			||||||
 | 
					  return <span {...props} className={clsx(className, 'truncate')} />
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										79
									
								
								src/components/stacked-layout.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										79
									
								
								src/components/stacked-layout.tsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,79 @@
 | 
				
			|||||||
 | 
					'use client'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import * as Headless from '@headlessui/react'
 | 
				
			||||||
 | 
					import React, { useState } from 'react'
 | 
				
			||||||
 | 
					import { NavbarItem } from './navbar'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					function OpenMenuIcon() {
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <svg data-slot="icon" viewBox="0 0 20 20" aria-hidden="true">
 | 
				
			||||||
 | 
					      <path d="M2 6.75C2 6.33579 2.33579 6 2.75 6H17.25C17.6642 6 18 6.33579 18 6.75C18 7.16421 17.6642 7.5 17.25 7.5H2.75C2.33579 7.5 2 7.16421 2 6.75ZM2 13.25C2 12.8358 2.33579 12.5 2.75 12.5H17.25C17.6642 12.5 18 12.8358 18 13.25C18 13.6642 17.6642 14 17.25 14H2.75C2.33579 14 2 13.6642 2 13.25Z" />
 | 
				
			||||||
 | 
					    </svg>
 | 
				
			||||||
 | 
					  )
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					function CloseMenuIcon() {
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <svg data-slot="icon" viewBox="0 0 20 20" aria-hidden="true">
 | 
				
			||||||
 | 
					      <path d="M6.28 5.22a.75.75 0 0 0-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 1 0 1.06 1.06L10 11.06l3.72 3.72a.75.75 0 1 0 1.06-1.06L11.06 10l3.72-3.72a.75.75 0 0 0-1.06-1.06L10 8.94 6.28 5.22Z" />
 | 
				
			||||||
 | 
					    </svg>
 | 
				
			||||||
 | 
					  )
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					function MobileSidebar({ open, close, children }: React.PropsWithChildren<{ open: boolean; close: () => void }>) {
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <Headless.Dialog open={open} onClose={close} className="lg:hidden">
 | 
				
			||||||
 | 
					      <Headless.DialogBackdrop
 | 
				
			||||||
 | 
					        transition
 | 
				
			||||||
 | 
					        className="fixed inset-0 bg-black/30 transition data-closed:opacity-0 data-enter:duration-300 data-enter:ease-out data-leave:duration-200 data-leave:ease-in"
 | 
				
			||||||
 | 
					      />
 | 
				
			||||||
 | 
					      <Headless.DialogPanel
 | 
				
			||||||
 | 
					        transition
 | 
				
			||||||
 | 
					        className="fixed inset-y-0 w-full max-w-80 p-2 transition duration-300 ease-in-out data-closed:-translate-x-full"
 | 
				
			||||||
 | 
					      >
 | 
				
			||||||
 | 
					        <div className="flex h-full flex-col rounded-lg bg-white shadow-xs ring-1 ring-zinc-950/5 dark:bg-zinc-900 dark:ring-white/10">
 | 
				
			||||||
 | 
					          <div className="-mb-3 px-4 pt-3">
 | 
				
			||||||
 | 
					            <Headless.CloseButton as={NavbarItem} aria-label="Close navigation">
 | 
				
			||||||
 | 
					              <CloseMenuIcon />
 | 
				
			||||||
 | 
					            </Headless.CloseButton>
 | 
				
			||||||
 | 
					          </div>
 | 
				
			||||||
 | 
					          {children}
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					      </Headless.DialogPanel>
 | 
				
			||||||
 | 
					    </Headless.Dialog>
 | 
				
			||||||
 | 
					  )
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function StackedLayout({
 | 
				
			||||||
 | 
					  navbar,
 | 
				
			||||||
 | 
					  sidebar,
 | 
				
			||||||
 | 
					  children,
 | 
				
			||||||
 | 
					}: React.PropsWithChildren<{ navbar: React.ReactNode; sidebar: React.ReactNode }>) {
 | 
				
			||||||
 | 
					  let [showSidebar, setShowSidebar] = useState(false)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <div className="relative isolate flex min-h-svh w-full flex-col bg-white lg:bg-zinc-100 dark:bg-zinc-900 dark:lg:bg-zinc-950">
 | 
				
			||||||
 | 
					      {/* Sidebar on mobile */}
 | 
				
			||||||
 | 
					      <MobileSidebar open={showSidebar} close={() => setShowSidebar(false)}>
 | 
				
			||||||
 | 
					        {sidebar}
 | 
				
			||||||
 | 
					      </MobileSidebar>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      {/* Navbar */}
 | 
				
			||||||
 | 
					      <header className="flex items-center px-4">
 | 
				
			||||||
 | 
					        <div className="py-2.5 lg:hidden">
 | 
				
			||||||
 | 
					          <NavbarItem onClick={() => setShowSidebar(true)} aria-label="Open navigation">
 | 
				
			||||||
 | 
					            <OpenMenuIcon />
 | 
				
			||||||
 | 
					          </NavbarItem>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					        <div className="min-w-0 flex-1">{navbar}</div>
 | 
				
			||||||
 | 
					      </header>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      {/* Content */}
 | 
				
			||||||
 | 
					      <main className="flex flex-1 flex-col pb-2 lg:px-2">
 | 
				
			||||||
 | 
					        <div className="grow p-6 lg:rounded-lg lg:bg-white lg:p-10 lg:shadow-xs lg:ring-1 lg:ring-zinc-950/5 dark:lg:bg-zinc-900 dark:lg:ring-white/10">
 | 
				
			||||||
 | 
					          <div className="mx-auto max-w-6xl">{children}</div>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					      </main>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					  )
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										195
									
								
								src/components/switch.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										195
									
								
								src/components/switch.tsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,195 @@
 | 
				
			|||||||
 | 
					import * as Headless from '@headlessui/react'
 | 
				
			||||||
 | 
					import clsx from 'clsx'
 | 
				
			||||||
 | 
					import type React from 'react'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function SwitchGroup({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) {
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <div
 | 
				
			||||||
 | 
					      data-slot="control"
 | 
				
			||||||
 | 
					      {...props}
 | 
				
			||||||
 | 
					      className={clsx(
 | 
				
			||||||
 | 
					        className,
 | 
				
			||||||
 | 
					        // Basic groups
 | 
				
			||||||
 | 
					        'space-y-3 **:data-[slot=label]:font-normal',
 | 
				
			||||||
 | 
					        // With descriptions
 | 
				
			||||||
 | 
					        'has-data-[slot=description]:space-y-6 has-data-[slot=description]:**:data-[slot=label]:font-medium'
 | 
				
			||||||
 | 
					      )}
 | 
				
			||||||
 | 
					    />
 | 
				
			||||||
 | 
					  )
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function SwitchField({
 | 
				
			||||||
 | 
					  className,
 | 
				
			||||||
 | 
					  ...props
 | 
				
			||||||
 | 
					}: { className?: string } & Omit<Headless.FieldProps, 'as' | 'className'>) {
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <Headless.Field
 | 
				
			||||||
 | 
					      data-slot="field"
 | 
				
			||||||
 | 
					      {...props}
 | 
				
			||||||
 | 
					      className={clsx(
 | 
				
			||||||
 | 
					        className,
 | 
				
			||||||
 | 
					        // Base layout
 | 
				
			||||||
 | 
					        'grid grid-cols-[1fr_auto] gap-x-8 gap-y-1 sm:grid-cols-[1fr_auto]',
 | 
				
			||||||
 | 
					        // Control layout
 | 
				
			||||||
 | 
					        '*:data-[slot=control]:col-start-2 *:data-[slot=control]:self-start sm:*:data-[slot=control]:mt-0.5',
 | 
				
			||||||
 | 
					        // Label layout
 | 
				
			||||||
 | 
					        '*:data-[slot=label]:col-start-1 *:data-[slot=label]:row-start-1',
 | 
				
			||||||
 | 
					        // Description layout
 | 
				
			||||||
 | 
					        '*:data-[slot=description]:col-start-1 *:data-[slot=description]:row-start-2',
 | 
				
			||||||
 | 
					        // With description
 | 
				
			||||||
 | 
					        'has-data-[slot=description]:**:data-[slot=label]:font-medium'
 | 
				
			||||||
 | 
					      )}
 | 
				
			||||||
 | 
					    />
 | 
				
			||||||
 | 
					  )
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const colors = {
 | 
				
			||||||
 | 
					  'dark/zinc': [
 | 
				
			||||||
 | 
					    '[--switch-bg-ring:var(--color-zinc-950)]/90 [--switch-bg:var(--color-zinc-900)] dark:[--switch-bg-ring:transparent] dark:[--switch-bg:var(--color-white)]/25',
 | 
				
			||||||
 | 
					    '[--switch-ring:var(--color-zinc-950)]/90 [--switch-shadow:var(--color-black)]/10 [--switch:white] dark:[--switch-ring:var(--color-zinc-700)]/90',
 | 
				
			||||||
 | 
					  ],
 | 
				
			||||||
 | 
					  'dark/white': [
 | 
				
			||||||
 | 
					    '[--switch-bg-ring:var(--color-zinc-950)]/90 [--switch-bg:var(--color-zinc-900)] dark:[--switch-bg-ring:transparent] dark:[--switch-bg:var(--color-white)]',
 | 
				
			||||||
 | 
					    '[--switch-ring:var(--color-zinc-950)]/90 [--switch-shadow:var(--color-black)]/10 [--switch:white] dark:[--switch-ring:transparent] dark:[--switch:var(--color-zinc-900)]',
 | 
				
			||||||
 | 
					  ],
 | 
				
			||||||
 | 
					  dark: [
 | 
				
			||||||
 | 
					    '[--switch-bg-ring:var(--color-zinc-950)]/90 [--switch-bg:var(--color-zinc-900)] dark:[--switch-bg-ring:var(--color-white)]/15',
 | 
				
			||||||
 | 
					    '[--switch-ring:var(--color-zinc-950)]/90 [--switch-shadow:var(--color-black)]/10 [--switch:white]',
 | 
				
			||||||
 | 
					  ],
 | 
				
			||||||
 | 
					  zinc: [
 | 
				
			||||||
 | 
					    '[--switch-bg-ring:var(--color-zinc-700)]/90 [--switch-bg:var(--color-zinc-600)] dark:[--switch-bg-ring:transparent]',
 | 
				
			||||||
 | 
					    '[--switch-shadow:var(--color-black)]/10 [--switch:white] [--switch-ring:var(--color-zinc-700)]/90',
 | 
				
			||||||
 | 
					  ],
 | 
				
			||||||
 | 
					  white: [
 | 
				
			||||||
 | 
					    '[--switch-bg-ring:var(--color-black)]/15 [--switch-bg:white] dark:[--switch-bg-ring:transparent]',
 | 
				
			||||||
 | 
					    '[--switch-shadow:var(--color-black)]/10 [--switch-ring:transparent] [--switch:var(--color-zinc-950)]',
 | 
				
			||||||
 | 
					  ],
 | 
				
			||||||
 | 
					  red: [
 | 
				
			||||||
 | 
					    '[--switch-bg-ring:var(--color-red-700)]/90 [--switch-bg:var(--color-red-600)] dark:[--switch-bg-ring:transparent]',
 | 
				
			||||||
 | 
					    '[--switch:white] [--switch-ring:var(--color-red-700)]/90 [--switch-shadow:var(--color-red-900)]/20',
 | 
				
			||||||
 | 
					  ],
 | 
				
			||||||
 | 
					  orange: [
 | 
				
			||||||
 | 
					    '[--switch-bg-ring:var(--color-orange-600)]/90 [--switch-bg:var(--color-orange-500)] dark:[--switch-bg-ring:transparent]',
 | 
				
			||||||
 | 
					    '[--switch:white] [--switch-ring:var(--color-orange-600)]/90 [--switch-shadow:var(--color-orange-900)]/20',
 | 
				
			||||||
 | 
					  ],
 | 
				
			||||||
 | 
					  amber: [
 | 
				
			||||||
 | 
					    '[--switch-bg-ring:var(--color-amber-500)]/80 [--switch-bg:var(--color-amber-400)] dark:[--switch-bg-ring:transparent]',
 | 
				
			||||||
 | 
					    '[--switch-ring:transparent] [--switch-shadow:transparent] [--switch:var(--color-amber-950)]',
 | 
				
			||||||
 | 
					  ],
 | 
				
			||||||
 | 
					  yellow: [
 | 
				
			||||||
 | 
					    '[--switch-bg-ring:var(--color-yellow-400)]/80 [--switch-bg:var(--color-yellow-300)] dark:[--switch-bg-ring:transparent]',
 | 
				
			||||||
 | 
					    '[--switch-ring:transparent] [--switch-shadow:transparent] [--switch:var(--color-yellow-950)]',
 | 
				
			||||||
 | 
					  ],
 | 
				
			||||||
 | 
					  lime: [
 | 
				
			||||||
 | 
					    '[--switch-bg-ring:var(--color-lime-400)]/80 [--switch-bg:var(--color-lime-300)] dark:[--switch-bg-ring:transparent]',
 | 
				
			||||||
 | 
					    '[--switch-ring:transparent] [--switch-shadow:transparent] [--switch:var(--color-lime-950)]',
 | 
				
			||||||
 | 
					  ],
 | 
				
			||||||
 | 
					  green: [
 | 
				
			||||||
 | 
					    '[--switch-bg-ring:var(--color-green-700)]/90 [--switch-bg:var(--color-green-600)] dark:[--switch-bg-ring:transparent]',
 | 
				
			||||||
 | 
					    '[--switch:white] [--switch-ring:var(--color-green-700)]/90 [--switch-shadow:var(--color-green-900)]/20',
 | 
				
			||||||
 | 
					  ],
 | 
				
			||||||
 | 
					  emerald: [
 | 
				
			||||||
 | 
					    '[--switch-bg-ring:var(--color-emerald-600)]/90 [--switch-bg:var(--color-emerald-500)] dark:[--switch-bg-ring:transparent]',
 | 
				
			||||||
 | 
					    '[--switch:white] [--switch-ring:var(--color-emerald-600)]/90 [--switch-shadow:var(--color-emerald-900)]/20',
 | 
				
			||||||
 | 
					  ],
 | 
				
			||||||
 | 
					  teal: [
 | 
				
			||||||
 | 
					    '[--switch-bg-ring:var(--color-teal-700)]/90 [--switch-bg:var(--color-teal-600)] dark:[--switch-bg-ring:transparent]',
 | 
				
			||||||
 | 
					    '[--switch:white] [--switch-ring:var(--color-teal-700)]/90 [--switch-shadow:var(--color-teal-900)]/20',
 | 
				
			||||||
 | 
					  ],
 | 
				
			||||||
 | 
					  cyan: [
 | 
				
			||||||
 | 
					    '[--switch-bg-ring:var(--color-cyan-400)]/80 [--switch-bg:var(--color-cyan-300)] dark:[--switch-bg-ring:transparent]',
 | 
				
			||||||
 | 
					    '[--switch-ring:transparent] [--switch-shadow:transparent] [--switch:var(--color-cyan-950)]',
 | 
				
			||||||
 | 
					  ],
 | 
				
			||||||
 | 
					  sky: [
 | 
				
			||||||
 | 
					    '[--switch-bg-ring:var(--color-sky-600)]/80 [--switch-bg:var(--color-sky-500)] dark:[--switch-bg-ring:transparent]',
 | 
				
			||||||
 | 
					    '[--switch:white] [--switch-ring:var(--color-sky-600)]/80 [--switch-shadow:var(--color-sky-900)]/20',
 | 
				
			||||||
 | 
					  ],
 | 
				
			||||||
 | 
					  blue: [
 | 
				
			||||||
 | 
					    '[--switch-bg-ring:var(--color-blue-700)]/90 [--switch-bg:var(--color-blue-600)] dark:[--switch-bg-ring:transparent]',
 | 
				
			||||||
 | 
					    '[--switch:white] [--switch-ring:var(--color-blue-700)]/90 [--switch-shadow:var(--color-blue-900)]/20',
 | 
				
			||||||
 | 
					  ],
 | 
				
			||||||
 | 
					  indigo: [
 | 
				
			||||||
 | 
					    '[--switch-bg-ring:var(--color-indigo-600)]/90 [--switch-bg:var(--color-indigo-500)] dark:[--switch-bg-ring:transparent]',
 | 
				
			||||||
 | 
					    '[--switch:white] [--switch-ring:var(--color-indigo-600)]/90 [--switch-shadow:var(--color-indigo-900)]/20',
 | 
				
			||||||
 | 
					  ],
 | 
				
			||||||
 | 
					  violet: [
 | 
				
			||||||
 | 
					    '[--switch-bg-ring:var(--color-violet-600)]/90 [--switch-bg:var(--color-violet-500)] dark:[--switch-bg-ring:transparent]',
 | 
				
			||||||
 | 
					    '[--switch:white] [--switch-ring:var(--color-violet-600)]/90 [--switch-shadow:var(--color-violet-900)]/20',
 | 
				
			||||||
 | 
					  ],
 | 
				
			||||||
 | 
					  purple: [
 | 
				
			||||||
 | 
					    '[--switch-bg-ring:var(--color-purple-600)]/90 [--switch-bg:var(--color-purple-500)] dark:[--switch-bg-ring:transparent]',
 | 
				
			||||||
 | 
					    '[--switch:white] [--switch-ring:var(--color-purple-600)]/90 [--switch-shadow:var(--color-purple-900)]/20',
 | 
				
			||||||
 | 
					  ],
 | 
				
			||||||
 | 
					  fuchsia: [
 | 
				
			||||||
 | 
					    '[--switch-bg-ring:var(--color-fuchsia-600)]/90 [--switch-bg:var(--color-fuchsia-500)] dark:[--switch-bg-ring:transparent]',
 | 
				
			||||||
 | 
					    '[--switch:white] [--switch-ring:var(--color-fuchsia-600)]/90 [--switch-shadow:var(--color-fuchsia-900)]/20',
 | 
				
			||||||
 | 
					  ],
 | 
				
			||||||
 | 
					  pink: [
 | 
				
			||||||
 | 
					    '[--switch-bg-ring:var(--color-pink-600)]/90 [--switch-bg:var(--color-pink-500)] dark:[--switch-bg-ring:transparent]',
 | 
				
			||||||
 | 
					    '[--switch:white] [--switch-ring:var(--color-pink-600)]/90 [--switch-shadow:var(--color-pink-900)]/20',
 | 
				
			||||||
 | 
					  ],
 | 
				
			||||||
 | 
					  rose: [
 | 
				
			||||||
 | 
					    '[--switch-bg-ring:var(--color-rose-600)]/90 [--switch-bg:var(--color-rose-500)] dark:[--switch-bg-ring:transparent]',
 | 
				
			||||||
 | 
					    '[--switch:white] [--switch-ring:var(--color-rose-600)]/90 [--switch-shadow:var(--color-rose-900)]/20',
 | 
				
			||||||
 | 
					  ],
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					type Color = keyof typeof colors
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function Switch({
 | 
				
			||||||
 | 
					  color = 'dark/zinc',
 | 
				
			||||||
 | 
					  className,
 | 
				
			||||||
 | 
					  ...props
 | 
				
			||||||
 | 
					}: {
 | 
				
			||||||
 | 
					  color?: Color
 | 
				
			||||||
 | 
					  className?: string
 | 
				
			||||||
 | 
					} & Omit<Headless.SwitchProps, 'as' | 'className' | 'children'>) {
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <Headless.Switch
 | 
				
			||||||
 | 
					      data-slot="control"
 | 
				
			||||||
 | 
					      {...props}
 | 
				
			||||||
 | 
					      className={clsx(
 | 
				
			||||||
 | 
					        className,
 | 
				
			||||||
 | 
					        // Base styles
 | 
				
			||||||
 | 
					        'group relative isolate inline-flex h-6 w-10 cursor-default rounded-full p-[3px] sm:h-5 sm:w-8',
 | 
				
			||||||
 | 
					        // Transitions
 | 
				
			||||||
 | 
					        'transition duration-0 ease-in-out data-changing:duration-200',
 | 
				
			||||||
 | 
					        // Outline and background color in forced-colors mode so switch is still visible
 | 
				
			||||||
 | 
					        'forced-colors:outline forced-colors:[--switch-bg:Highlight] dark:forced-colors:[--switch-bg:Highlight]',
 | 
				
			||||||
 | 
					        // Unchecked
 | 
				
			||||||
 | 
					        'bg-zinc-200 ring-1 ring-black/5 ring-inset dark:bg-white/5 dark:ring-white/15',
 | 
				
			||||||
 | 
					        // Checked
 | 
				
			||||||
 | 
					        'data-checked:bg-(--switch-bg) data-checked:ring-(--switch-bg-ring) dark:data-checked:bg-(--switch-bg) dark:data-checked:ring-(--switch-bg-ring)',
 | 
				
			||||||
 | 
					        // Focus
 | 
				
			||||||
 | 
					        'focus:outline-hidden data-focus:outline-2 data-focus:outline-offset-2 data-focus:outline-blue-500',
 | 
				
			||||||
 | 
					        // Hover
 | 
				
			||||||
 | 
					        'data-hover:ring-black/15 data-hover:data-checked:ring-(--switch-bg-ring)',
 | 
				
			||||||
 | 
					        'dark:data-hover:ring-white/25 dark:data-hover:data-checked:ring-(--switch-bg-ring)',
 | 
				
			||||||
 | 
					        // Disabled
 | 
				
			||||||
 | 
					        'data-disabled:bg-zinc-200 data-disabled:opacity-50 data-disabled:data-checked:bg-zinc-200 data-disabled:data-checked:ring-black/5',
 | 
				
			||||||
 | 
					        'dark:data-disabled:bg-white/15 dark:data-disabled:data-checked:bg-white/15 dark:data-disabled:data-checked:ring-white/15',
 | 
				
			||||||
 | 
					        // Color specific styles
 | 
				
			||||||
 | 
					        colors[color]
 | 
				
			||||||
 | 
					      )}
 | 
				
			||||||
 | 
					    >
 | 
				
			||||||
 | 
					      <span
 | 
				
			||||||
 | 
					        aria-hidden="true"
 | 
				
			||||||
 | 
					        className={clsx(
 | 
				
			||||||
 | 
					          // Basic layout
 | 
				
			||||||
 | 
					          'pointer-events-none relative inline-block size-[1.125rem] rounded-full sm:size-3.5',
 | 
				
			||||||
 | 
					          // Transition
 | 
				
			||||||
 | 
					          'translate-x-0 transition duration-200 ease-in-out',
 | 
				
			||||||
 | 
					          // Invisible border so the switch is still visible in forced-colors mode
 | 
				
			||||||
 | 
					          'border border-transparent',
 | 
				
			||||||
 | 
					          // Unchecked
 | 
				
			||||||
 | 
					          'bg-white shadow-sm ring-1 ring-black/5',
 | 
				
			||||||
 | 
					          // Checked
 | 
				
			||||||
 | 
					          'group-data-checked:bg-(--switch) group-data-checked:shadow-(--switch-shadow) group-data-checked:ring-(--switch-ring)',
 | 
				
			||||||
 | 
					          'group-data-checked:translate-x-4 sm:group-data-checked:translate-x-3',
 | 
				
			||||||
 | 
					          // Disabled
 | 
				
			||||||
 | 
					          'group-data-checked:group-data-disabled:bg-white group-data-checked:group-data-disabled:shadow-sm group-data-checked:group-data-disabled:ring-black/5'
 | 
				
			||||||
 | 
					        )}
 | 
				
			||||||
 | 
					      />
 | 
				
			||||||
 | 
					    </Headless.Switch>
 | 
				
			||||||
 | 
					  )
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										124
									
								
								src/components/table.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										124
									
								
								src/components/table.tsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,124 @@
 | 
				
			|||||||
 | 
					'use client'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import clsx from 'clsx'
 | 
				
			||||||
 | 
					import type React from 'react'
 | 
				
			||||||
 | 
					import { createContext, useContext, useState } from 'react'
 | 
				
			||||||
 | 
					import { Link } from './link'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const TableContext = createContext<{ bleed: boolean; dense: boolean; grid: boolean; striped: boolean }>({
 | 
				
			||||||
 | 
					  bleed: false,
 | 
				
			||||||
 | 
					  dense: false,
 | 
				
			||||||
 | 
					  grid: false,
 | 
				
			||||||
 | 
					  striped: false,
 | 
				
			||||||
 | 
					})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function Table({
 | 
				
			||||||
 | 
					  bleed = false,
 | 
				
			||||||
 | 
					  dense = false,
 | 
				
			||||||
 | 
					  grid = false,
 | 
				
			||||||
 | 
					  striped = false,
 | 
				
			||||||
 | 
					  className,
 | 
				
			||||||
 | 
					  children,
 | 
				
			||||||
 | 
					  ...props
 | 
				
			||||||
 | 
					}: { bleed?: boolean; dense?: boolean; grid?: boolean; striped?: boolean } & React.ComponentPropsWithoutRef<'div'>) {
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <TableContext.Provider value={{ bleed, dense, grid, striped } as React.ContextType<typeof TableContext>}>
 | 
				
			||||||
 | 
					      <div className="flow-root">
 | 
				
			||||||
 | 
					        <div {...props} className={clsx(className, '-mx-(--gutter) overflow-x-auto whitespace-nowrap')}>
 | 
				
			||||||
 | 
					          <div className={clsx('inline-block min-w-full align-middle', !bleed && 'sm:px-(--gutter)')}>
 | 
				
			||||||
 | 
					            <table className="min-w-full text-left text-sm/6 text-zinc-950 dark:text-white">{children}</table>
 | 
				
			||||||
 | 
					          </div>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					    </TableContext.Provider>
 | 
				
			||||||
 | 
					  )
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function TableHead({ className, ...props }: React.ComponentPropsWithoutRef<'thead'>) {
 | 
				
			||||||
 | 
					  return <thead {...props} className={clsx(className, 'text-zinc-500 dark:text-zinc-400')} />
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function TableBody(props: React.ComponentPropsWithoutRef<'tbody'>) {
 | 
				
			||||||
 | 
					  return <tbody {...props} />
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const TableRowContext = createContext<{ href?: string; target?: string; title?: string }>({
 | 
				
			||||||
 | 
					  href: undefined,
 | 
				
			||||||
 | 
					  target: undefined,
 | 
				
			||||||
 | 
					  title: undefined,
 | 
				
			||||||
 | 
					})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function TableRow({
 | 
				
			||||||
 | 
					  href,
 | 
				
			||||||
 | 
					  target,
 | 
				
			||||||
 | 
					  title,
 | 
				
			||||||
 | 
					  className,
 | 
				
			||||||
 | 
					  ...props
 | 
				
			||||||
 | 
					}: { href?: string; target?: string; title?: string } & React.ComponentPropsWithoutRef<'tr'>) {
 | 
				
			||||||
 | 
					  let { striped } = useContext(TableContext)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <TableRowContext.Provider value={{ href, target, title } as React.ContextType<typeof TableRowContext>}>
 | 
				
			||||||
 | 
					      <tr
 | 
				
			||||||
 | 
					        {...props}
 | 
				
			||||||
 | 
					        className={clsx(
 | 
				
			||||||
 | 
					          className,
 | 
				
			||||||
 | 
					          href &&
 | 
				
			||||||
 | 
					            'has-[[data-row-link][data-focus]]:outline-2 has-[[data-row-link][data-focus]]:-outline-offset-2 has-[[data-row-link][data-focus]]:outline-blue-500 dark:focus-within:bg-white/[2.5%]',
 | 
				
			||||||
 | 
					          striped && 'even:bg-zinc-950/[2.5%] dark:even:bg-white/[2.5%]',
 | 
				
			||||||
 | 
					          href && striped && 'hover:bg-zinc-950/5 dark:hover:bg-white/5',
 | 
				
			||||||
 | 
					          href && !striped && 'hover:bg-zinc-950/[2.5%] dark:hover:bg-white/[2.5%]'
 | 
				
			||||||
 | 
					        )}
 | 
				
			||||||
 | 
					      />
 | 
				
			||||||
 | 
					    </TableRowContext.Provider>
 | 
				
			||||||
 | 
					  )
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function TableHeader({ className, ...props }: React.ComponentPropsWithoutRef<'th'>) {
 | 
				
			||||||
 | 
					  let { bleed, grid } = useContext(TableContext)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <th
 | 
				
			||||||
 | 
					      {...props}
 | 
				
			||||||
 | 
					      className={clsx(
 | 
				
			||||||
 | 
					        className,
 | 
				
			||||||
 | 
					        'border-b border-b-zinc-950/10 px-4 py-2 font-medium first:pl-(--gutter,--spacing(2)) last:pr-(--gutter,--spacing(2)) dark:border-b-white/10',
 | 
				
			||||||
 | 
					        grid && 'border-l border-l-zinc-950/5 first:border-l-0 dark:border-l-white/5',
 | 
				
			||||||
 | 
					        !bleed && 'sm:first:pl-1 sm:last:pr-1'
 | 
				
			||||||
 | 
					      )}
 | 
				
			||||||
 | 
					    />
 | 
				
			||||||
 | 
					  )
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function TableCell({ className, children, ...props }: React.ComponentPropsWithoutRef<'td'>) {
 | 
				
			||||||
 | 
					  let { bleed, dense, grid, striped } = useContext(TableContext)
 | 
				
			||||||
 | 
					  let { href, target, title } = useContext(TableRowContext)
 | 
				
			||||||
 | 
					  let [cellRef, setCellRef] = useState<HTMLElement | null>(null)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <td
 | 
				
			||||||
 | 
					      ref={href ? setCellRef : undefined}
 | 
				
			||||||
 | 
					      {...props}
 | 
				
			||||||
 | 
					      className={clsx(
 | 
				
			||||||
 | 
					        className,
 | 
				
			||||||
 | 
					        'relative px-4 first:pl-(--gutter,--spacing(2)) last:pr-(--gutter,--spacing(2))',
 | 
				
			||||||
 | 
					        !striped && 'border-b border-zinc-950/5 dark:border-white/5',
 | 
				
			||||||
 | 
					        grid && 'border-l border-l-zinc-950/5 first:border-l-0 dark:border-l-white/5',
 | 
				
			||||||
 | 
					        dense ? 'py-2.5' : 'py-4',
 | 
				
			||||||
 | 
					        !bleed && 'sm:first:pl-1 sm:last:pr-1'
 | 
				
			||||||
 | 
					      )}
 | 
				
			||||||
 | 
					    >
 | 
				
			||||||
 | 
					      {href && (
 | 
				
			||||||
 | 
					        <Link
 | 
				
			||||||
 | 
					          data-row-link
 | 
				
			||||||
 | 
					          href={href}
 | 
				
			||||||
 | 
					          target={target}
 | 
				
			||||||
 | 
					          aria-label={title}
 | 
				
			||||||
 | 
					          tabIndex={cellRef?.previousElementSibling === null ? 0 : -1}
 | 
				
			||||||
 | 
					          className="absolute inset-0 focus:outline-hidden"
 | 
				
			||||||
 | 
					        />
 | 
				
			||||||
 | 
					      )}
 | 
				
			||||||
 | 
					      {children}
 | 
				
			||||||
 | 
					    </td>
 | 
				
			||||||
 | 
					  )
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										40
									
								
								src/components/text.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										40
									
								
								src/components/text.tsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,40 @@
 | 
				
			|||||||
 | 
					import clsx from 'clsx'
 | 
				
			||||||
 | 
					import { Link } from './link'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function Text({ className, ...props }: React.ComponentPropsWithoutRef<'p'>) {
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <p
 | 
				
			||||||
 | 
					      data-slot="text"
 | 
				
			||||||
 | 
					      {...props}
 | 
				
			||||||
 | 
					      className={clsx(className, 'text-base/6 text-zinc-500 sm:text-sm/6 dark:text-zinc-400')}
 | 
				
			||||||
 | 
					    />
 | 
				
			||||||
 | 
					  )
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function TextLink({ className, ...props }: React.ComponentPropsWithoutRef<typeof Link>) {
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <Link
 | 
				
			||||||
 | 
					      {...props}
 | 
				
			||||||
 | 
					      className={clsx(
 | 
				
			||||||
 | 
					        className,
 | 
				
			||||||
 | 
					        'text-zinc-950 underline decoration-zinc-950/50 data-hover:decoration-zinc-950 dark:text-white dark:decoration-white/50 dark:data-hover:decoration-white'
 | 
				
			||||||
 | 
					      )}
 | 
				
			||||||
 | 
					    />
 | 
				
			||||||
 | 
					  )
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function Strong({ className, ...props }: React.ComponentPropsWithoutRef<'strong'>) {
 | 
				
			||||||
 | 
					  return <strong {...props} className={clsx(className, 'font-medium text-zinc-950 dark:text-white')} />
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function Code({ className, ...props }: React.ComponentPropsWithoutRef<'code'>) {
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <code
 | 
				
			||||||
 | 
					      {...props}
 | 
				
			||||||
 | 
					      className={clsx(
 | 
				
			||||||
 | 
					        className,
 | 
				
			||||||
 | 
					        'rounded-sm border border-zinc-950/10 bg-zinc-950/[2.5%] px-0.5 text-sm font-medium text-zinc-950 sm:text-[0.8125rem] dark:border-white/20 dark:bg-white/5 dark:text-white'
 | 
				
			||||||
 | 
					      )}
 | 
				
			||||||
 | 
					    />
 | 
				
			||||||
 | 
					  )
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										54
									
								
								src/components/textarea.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										54
									
								
								src/components/textarea.tsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,54 @@
 | 
				
			|||||||
 | 
					import * as Headless from '@headlessui/react'
 | 
				
			||||||
 | 
					import clsx from 'clsx'
 | 
				
			||||||
 | 
					import React, { forwardRef } from 'react'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const Textarea = forwardRef(function Textarea(
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
 | 
					    className,
 | 
				
			||||||
 | 
					    resizable = true,
 | 
				
			||||||
 | 
					    ...props
 | 
				
			||||||
 | 
					  }: { className?: string; resizable?: boolean } & Omit<Headless.TextareaProps, 'as' | 'className'>,
 | 
				
			||||||
 | 
					  ref: React.ForwardedRef<HTMLTextAreaElement>
 | 
				
			||||||
 | 
					) {
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <span
 | 
				
			||||||
 | 
					      data-slot="control"
 | 
				
			||||||
 | 
					      className={clsx([
 | 
				
			||||||
 | 
					        className,
 | 
				
			||||||
 | 
					        // Basic layout
 | 
				
			||||||
 | 
					        'relative block w-full',
 | 
				
			||||||
 | 
					        // Background color + shadow applied to inset pseudo element, so shadow blends with border in light mode
 | 
				
			||||||
 | 
					        'before:absolute before:inset-px before:rounded-[calc(var(--radius-lg)-1px)] before:bg-white before:shadow-sm',
 | 
				
			||||||
 | 
					        // Background color is moved to control and shadow is removed in dark mode so hide `before` pseudo
 | 
				
			||||||
 | 
					        'dark:before:hidden',
 | 
				
			||||||
 | 
					        // Focus ring
 | 
				
			||||||
 | 
					        'after:pointer-events-none after:absolute after:inset-0 after:rounded-lg after:ring-transparent after:ring-inset sm:focus-within:after:ring-2 sm:focus-within:after:ring-blue-500',
 | 
				
			||||||
 | 
					        // Disabled state
 | 
				
			||||||
 | 
					        'has-data-disabled:opacity-50 has-data-disabled:before:bg-zinc-950/5 has-data-disabled:before:shadow-none',
 | 
				
			||||||
 | 
					      ])}
 | 
				
			||||||
 | 
					    >
 | 
				
			||||||
 | 
					      <Headless.Textarea
 | 
				
			||||||
 | 
					        ref={ref}
 | 
				
			||||||
 | 
					        {...props}
 | 
				
			||||||
 | 
					        className={clsx([
 | 
				
			||||||
 | 
					          // Basic layout
 | 
				
			||||||
 | 
					          'relative block h-full w-full appearance-none rounded-lg px-[calc(--spacing(3.5)-1px)] py-[calc(--spacing(2.5)-1px)] sm:px-[calc(--spacing(3)-1px)] sm:py-[calc(--spacing(1.5)-1px)]',
 | 
				
			||||||
 | 
					          // Typography
 | 
				
			||||||
 | 
					          'text-base/6 text-zinc-950 placeholder:text-zinc-500 sm:text-sm/6 dark:text-white',
 | 
				
			||||||
 | 
					          // Border
 | 
				
			||||||
 | 
					          'border border-zinc-950/10 data-hover:border-zinc-950/20 dark:border-white/10 dark:data-hover:border-white/20',
 | 
				
			||||||
 | 
					          // Background color
 | 
				
			||||||
 | 
					          'bg-transparent dark:bg-white/5',
 | 
				
			||||||
 | 
					          // Hide default focus styles
 | 
				
			||||||
 | 
					          'focus:outline-hidden',
 | 
				
			||||||
 | 
					          // Invalid state
 | 
				
			||||||
 | 
					          'data-invalid:border-red-500 data-invalid:data-hover:border-red-500 dark:data-invalid:border-red-600 dark:data-invalid:data-hover:border-red-600',
 | 
				
			||||||
 | 
					          // Disabled state
 | 
				
			||||||
 | 
					          'disabled:border-zinc-950/20 dark:disabled:border-white/15 dark:disabled:bg-white/[2.5%] dark:data-hover:disabled:border-white/15',
 | 
				
			||||||
 | 
					          // Resizable
 | 
				
			||||||
 | 
					          resizable ? 'resize-y' : 'resize-none',
 | 
				
			||||||
 | 
					        ])}
 | 
				
			||||||
 | 
					      />
 | 
				
			||||||
 | 
					    </span>
 | 
				
			||||||
 | 
					  )
 | 
				
			||||||
 | 
					})
 | 
				
			||||||
@ -5,6 +5,11 @@ export const Header: GlobalConfig = {
 | 
				
			|||||||
  slug: 'header',
 | 
					  slug: 'header',
 | 
				
			||||||
  label: 'Header Nav',
 | 
					  label: 'Header Nav',
 | 
				
			||||||
  fields: [
 | 
					  fields: [
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					      name: 'logo',
 | 
				
			||||||
 | 
					      type: 'relationship',
 | 
				
			||||||
 | 
					      relationTo: 'media',
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
      name: 'headerLinks',
 | 
					      name: 'headerLinks',
 | 
				
			||||||
      type: 'array',
 | 
					      type: 'array',
 | 
				
			||||||
 | 
				
			|||||||
@ -181,6 +181,8 @@ export interface Book {
 | 
				
			|||||||
  authors?: (number | Author)[] | null;
 | 
					  authors?: (number | Author)[] | null;
 | 
				
			||||||
  pages?: number | null;
 | 
					  pages?: number | null;
 | 
				
			||||||
  lcc?: string | null;
 | 
					  lcc?: string | null;
 | 
				
			||||||
 | 
					  isbn?: string | null;
 | 
				
			||||||
 | 
					  asin?: string | null;
 | 
				
			||||||
  publication?: string | null;
 | 
					  publication?: string | null;
 | 
				
			||||||
  date?: string | null;
 | 
					  date?: string | null;
 | 
				
			||||||
  genre?: (number | Genre)[] | null;
 | 
					  genre?: (number | Genre)[] | null;
 | 
				
			||||||
@ -420,6 +422,8 @@ export interface BooksSelect<T extends boolean = true> {
 | 
				
			|||||||
  authors?: T;
 | 
					  authors?: T;
 | 
				
			||||||
  pages?: T;
 | 
					  pages?: T;
 | 
				
			||||||
  lcc?: T;
 | 
					  lcc?: T;
 | 
				
			||||||
 | 
					  isbn?: T;
 | 
				
			||||||
 | 
					  asin?: T;
 | 
				
			||||||
  publication?: T;
 | 
					  publication?: T;
 | 
				
			||||||
  date?: T;
 | 
					  date?: T;
 | 
				
			||||||
  genre?: T;
 | 
					  genre?: T;
 | 
				
			||||||
@ -535,6 +539,7 @@ export interface PayloadMigrationsSelect<T extends boolean = true> {
 | 
				
			|||||||
 */
 | 
					 */
 | 
				
			||||||
export interface Header {
 | 
					export interface Header {
 | 
				
			||||||
  id: number;
 | 
					  id: number;
 | 
				
			||||||
 | 
					  logo?: (number | null) | Media;
 | 
				
			||||||
  headerLinks?:
 | 
					  headerLinks?:
 | 
				
			||||||
    | {
 | 
					    | {
 | 
				
			||||||
        label?: string | null;
 | 
					        label?: string | null;
 | 
				
			||||||
@ -551,6 +556,7 @@ export interface Header {
 | 
				
			|||||||
 * via the `definition` "header_select".
 | 
					 * via the `definition` "header_select".
 | 
				
			||||||
 */
 | 
					 */
 | 
				
			||||||
export interface HeaderSelect<T extends boolean = true> {
 | 
					export interface HeaderSelect<T extends boolean = true> {
 | 
				
			||||||
 | 
					  logo?: T;
 | 
				
			||||||
  headerLinks?:
 | 
					  headerLinks?:
 | 
				
			||||||
    | T
 | 
					    | T
 | 
				
			||||||
    | {
 | 
					    | {
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										9
									
								
								tailwind.config.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								tailwind.config.js
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,9 @@
 | 
				
			|||||||
 | 
					/** @type {import('tailwindcss').Config} */
 | 
				
			||||||
 | 
					export default {
 | 
				
			||||||
 | 
					  content: ['./src/**/*.{jsx,tsx}'], // tell tailwind where to look
 | 
				
			||||||
 | 
					  darkMode: ['selector', '[data-theme="dark"]', '.dark'],
 | 
				
			||||||
 | 
					  theme: {
 | 
				
			||||||
 | 
					    extend: {},
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  plugins: [],
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user