feat: initialize tailwindui/catalyst
This commit is contained in:
parent
00aecab225
commit
57c688c4e7
30 changed files with 2671 additions and 139 deletions
|
|
@ -6,7 +6,7 @@
|
|||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Vite + React + TS</title>
|
||||
</head>
|
||||
<body>
|
||||
<body class="text-zinc-950 antialiased lg:bg-zinc-100 dark:bg-zinc-900 dark:text-white dark:lg:bg-zinc-950">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
|
|
|
|||
42
src/App.css
42
src/App.css
|
|
@ -1,42 +0,0 @@
|
|||
#root {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 6em;
|
||||
padding: 1.5em;
|
||||
will-change: filter;
|
||||
transition: filter 300ms;
|
||||
}
|
||||
.logo:hover {
|
||||
filter: drop-shadow(0 0 2em #646cffaa);
|
||||
}
|
||||
.logo.react:hover {
|
||||
filter: drop-shadow(0 0 2em #61dafbaa);
|
||||
}
|
||||
|
||||
@keyframes logo-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
a:nth-of-type(2) .logo {
|
||||
animation: logo-spin infinite 20s linear;
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 2em;
|
||||
}
|
||||
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
||||
32
src/App.tsx
32
src/App.tsx
|
|
@ -1,34 +1,10 @@
|
|||
import { useState } from 'react'
|
||||
import reactLogo from './assets/react.svg'
|
||||
import viteLogo from '/vite.svg'
|
||||
import './App.css'
|
||||
import React from 'react'
|
||||
import { Layout } from './components/layout'
|
||||
|
||||
function App() {
|
||||
const [count, setCount] = useState(0)
|
||||
function App({children}: {children: React.ReactNode}) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<a href="https://vitejs.dev" target="_blank">
|
||||
<img src={viteLogo} className="logo" alt="Vite logo" />
|
||||
</a>
|
||||
<a href="https://react.dev" target="_blank">
|
||||
<img src={reactLogo} className="logo react" alt="React logo" />
|
||||
</a>
|
||||
</div>
|
||||
<h1>Vite + React</h1>
|
||||
<div className="card">
|
||||
<button onClick={() => setCount((count) => count + 1)}>
|
||||
count is {count}
|
||||
</button>
|
||||
<p>
|
||||
Edit <code>src/App.tsx</code> and save to test HMR
|
||||
</p>
|
||||
</div>
|
||||
<p className="read-the-docs">
|
||||
Click on the Vite and React logos to learn more
|
||||
</p>
|
||||
</>
|
||||
<Layout>{children}</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
193
src/components/layout.tsx
Normal file
193
src/components/layout.tsx
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
import { Avatar } from './ui/avatar'
|
||||
import {
|
||||
Dropdown,
|
||||
DropdownButton,
|
||||
DropdownDivider,
|
||||
DropdownItem,
|
||||
DropdownLabel,
|
||||
DropdownMenu,
|
||||
} from './ui/dropdown'
|
||||
import { Navbar, NavbarItem, NavbarSection, NavbarSpacer } from './ui/navbar'
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarBody,
|
||||
SidebarFooter,
|
||||
SidebarHeader,
|
||||
SidebarHeading,
|
||||
SidebarItem,
|
||||
SidebarLabel,
|
||||
SidebarSection,
|
||||
SidebarSpacer,
|
||||
} from './ui/sidebar'
|
||||
import { SidebarLayout } from './ui/sidebar-layout'
|
||||
import {
|
||||
ArrowRightStartOnRectangleIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronUpIcon,
|
||||
Cog8ToothIcon,
|
||||
LightBulbIcon,
|
||||
PlusIcon,
|
||||
ShieldCheckIcon,
|
||||
UserCircleIcon,
|
||||
} from '@heroicons/react/16/solid'
|
||||
import {
|
||||
Cog6ToothIcon,
|
||||
HomeIcon,
|
||||
QuestionMarkCircleIcon,
|
||||
SparklesIcon,
|
||||
Square2StackIcon,
|
||||
TicketIcon,
|
||||
} from '@heroicons/react/20/solid'
|
||||
|
||||
function AccountDropdownMenu({ anchor }: { anchor: 'top start' | 'bottom end' }) {
|
||||
return (
|
||||
<DropdownMenu className="min-w-64" anchor={anchor}>
|
||||
<DropdownItem href="#">
|
||||
<UserCircleIcon />
|
||||
<DropdownLabel>My account</DropdownLabel>
|
||||
</DropdownItem>
|
||||
<DropdownDivider />
|
||||
<DropdownItem href="#">
|
||||
<ShieldCheckIcon />
|
||||
<DropdownLabel>Privacy policy</DropdownLabel>
|
||||
</DropdownItem>
|
||||
<DropdownItem href="#">
|
||||
<LightBulbIcon />
|
||||
<DropdownLabel>Share feedback</DropdownLabel>
|
||||
</DropdownItem>
|
||||
<DropdownDivider />
|
||||
<DropdownItem href="#">
|
||||
<ArrowRightStartOnRectangleIcon />
|
||||
<DropdownLabel>Sign out</DropdownLabel>
|
||||
</DropdownItem>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
const events = [
|
||||
{ id: 1, name: 'Catalyst Launch Party', url: '/events/1' },
|
||||
{ id: 2, name: 'Big Events Conference', url: '/events/2' },
|
||||
{ id: 3, name: 'Big Events Expo', url: '/events/3' },
|
||||
{ id: 4, name: 'Big Events Summit', url: '/events/4' },
|
||||
]
|
||||
|
||||
export function Layout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
|
||||
return (
|
||||
<SidebarLayout
|
||||
navbar={
|
||||
<Navbar>
|
||||
<NavbarSpacer />
|
||||
<NavbarSection>
|
||||
<Dropdown>
|
||||
<DropdownButton as={NavbarItem}>
|
||||
<Avatar src="/users/erica.jpg" square />
|
||||
</DropdownButton>
|
||||
<AccountDropdownMenu anchor="bottom end" />
|
||||
</Dropdown>
|
||||
</NavbarSection>
|
||||
</Navbar>
|
||||
}
|
||||
sidebar={
|
||||
<Sidebar>
|
||||
<SidebarHeader>
|
||||
<Dropdown>
|
||||
<DropdownButton as={SidebarItem}>
|
||||
<Avatar src="/teams/catalyst.svg" />
|
||||
<SidebarLabel>Catalyst</SidebarLabel>
|
||||
<ChevronDownIcon />
|
||||
</DropdownButton>
|
||||
<DropdownMenu className="min-w-80 lg:min-w-64" anchor="bottom start">
|
||||
<DropdownItem href="/settings">
|
||||
<Cog8ToothIcon />
|
||||
<DropdownLabel>Settings</DropdownLabel>
|
||||
</DropdownItem>
|
||||
<DropdownDivider />
|
||||
<DropdownItem href="#">
|
||||
<Avatar slot="icon" src="/teams/catalyst.svg" />
|
||||
<DropdownLabel>Catalyst</DropdownLabel>
|
||||
</DropdownItem>
|
||||
<DropdownItem href="#">
|
||||
<Avatar slot="icon" initials="BE" className="bg-purple-500 text-white" />
|
||||
<DropdownLabel>Big Events</DropdownLabel>
|
||||
</DropdownItem>
|
||||
<DropdownDivider />
|
||||
<DropdownItem href="#">
|
||||
<PlusIcon />
|
||||
<DropdownLabel>New team…</DropdownLabel>
|
||||
</DropdownItem>
|
||||
</DropdownMenu>
|
||||
</Dropdown>
|
||||
</SidebarHeader>
|
||||
|
||||
<SidebarBody>
|
||||
<SidebarSection>
|
||||
<SidebarItem href="/" current={true} >
|
||||
<HomeIcon />
|
||||
<SidebarLabel>Home</SidebarLabel>
|
||||
</SidebarItem>
|
||||
<SidebarItem href="/events" >
|
||||
<Square2StackIcon />
|
||||
<SidebarLabel>Events</SidebarLabel>
|
||||
</SidebarItem>
|
||||
<SidebarItem href="/orders" >
|
||||
<TicketIcon />
|
||||
<SidebarLabel>Orders</SidebarLabel>
|
||||
</SidebarItem>
|
||||
<SidebarItem href="/settings">
|
||||
<Cog6ToothIcon />
|
||||
<SidebarLabel>Settings</SidebarLabel>
|
||||
</SidebarItem>
|
||||
</SidebarSection>
|
||||
|
||||
<SidebarSection className="max-lg:hidden">
|
||||
<SidebarHeading>Upcoming Events</SidebarHeading>
|
||||
{events.map((event) => (
|
||||
<SidebarItem key={event.id} href={event.url}>
|
||||
{event.name}
|
||||
</SidebarItem>
|
||||
))}
|
||||
</SidebarSection>
|
||||
|
||||
<SidebarSpacer />
|
||||
|
||||
<SidebarSection>
|
||||
<SidebarItem href="#">
|
||||
<QuestionMarkCircleIcon />
|
||||
<SidebarLabel>Support</SidebarLabel>
|
||||
</SidebarItem>
|
||||
<SidebarItem href="#">
|
||||
<SparklesIcon />
|
||||
<SidebarLabel>Changelog</SidebarLabel>
|
||||
</SidebarItem>
|
||||
</SidebarSection>
|
||||
</SidebarBody>
|
||||
|
||||
<SidebarFooter className="max-lg:hidden">
|
||||
<Dropdown>
|
||||
<DropdownButton as={SidebarItem}>
|
||||
<span className="flex min-w-0 items-center gap-3">
|
||||
<Avatar src="/users/erica.jpg" className="size-10" square alt="" />
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-sm/5 font-medium text-zinc-950 dark:text-white">Erica</span>
|
||||
<span className="block truncate text-xs/5 font-normal text-zinc-500 dark:text-zinc-400">
|
||||
erica@example.com
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
<ChevronUpIcon />
|
||||
</DropdownButton>
|
||||
<AccountDropdownMenu anchor="top start" />
|
||||
</Dropdown>
|
||||
</SidebarFooter>
|
||||
</Sidebar>
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</SidebarLayout>
|
||||
)
|
||||
}
|
||||
95
src/components/ui/alert.tsx
Normal file
95
src/components/ui/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,
|
||||
'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 data-[closed]:data-[enter]:scale-95 data-[closed]:opacity-0 data-[enter]:ease-out data-[leave]:ease-in'
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</Headless.DialogPanel>
|
||||
</div>
|
||||
</div>
|
||||
</Headless.Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export function AlertTitle({
|
||||
className,
|
||||
...props
|
||||
}: { className?: string } & Omit<Headless.DialogTitleProps, 'className'>) {
|
||||
return (
|
||||
<Headless.DialogTitle
|
||||
{...props}
|
||||
className={clsx(
|
||||
className,
|
||||
'text-balance text-center text-base/6 font-semibold text-zinc-950 sm:text-wrap sm:text-left sm:text-sm/6 dark:text-white'
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function AlertDescription({
|
||||
className,
|
||||
...props
|
||||
}: { className?: string } & Omit<Headless.DescriptionProps<typeof Text>, 'className'>) {
|
||||
return (
|
||||
<Headless.Description
|
||||
as={Text}
|
||||
{...props}
|
||||
className={clsx(className, 'mt-2 text-pretty text-center 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'
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
84
src/components/ui/avatar.tsx
Normal file
84
src/components/ui/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%] [--ring-opacity:20%] *:col-start-1 *:row-start-1',
|
||||
'outline outline-1 -outline-offset-1 outline-black/[--ring-opacity] dark:outline-white/[--ring-opacity]',
|
||||
// Add the correct border radius
|
||||
square ? 'rounded-[--avatar-radius] *:rounded-[--avatar-radius]' : 'rounded-full *:rounded-full'
|
||||
)}
|
||||
>
|
||||
{initials && (
|
||||
<svg
|
||||
className="size-full select-none fill-current p-[5%] text-[48px] font-medium uppercase"
|
||||
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, '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-none data-[focus]:outline 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/ui/badge.tsx
Normal file
82
src/components/ui/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, 'className'>
|
||||
| Omit<React.ComponentPropsWithoutRef<typeof Link>, 'className'>
|
||||
),
|
||||
ref: React.ForwardedRef<HTMLElement>
|
||||
) {
|
||||
let classes = clsx(
|
||||
className,
|
||||
'group relative inline-flex rounded-md focus:outline-none data-[focus]:outline 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/ui/button.tsx
Normal file
204
src/components/ui/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-center justify-center gap-x-2 rounded-lg border text-base/6 font-semibold',
|
||||
// Sizing
|
||||
'px-[calc(theme(spacing[3.5])-1px)] py-[calc(theme(spacing[2.5])-1px)] sm:px-[calc(theme(spacing.3)-1px)] sm:py-[calc(theme(spacing[1.5])-1px)] sm:text-sm/6',
|
||||
// Focus
|
||||
'focus:outline-none 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]]:text-[--btn-icon] [&>[data-slot=icon]]:sm:my-1 [&>[data-slot=icon]]:sm: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(theme(borderRadius.lg)-1px)] before:bg-[--btn-bg]',
|
||||
// Drop shadow, applied to the inset `before` layer so it blends with the border
|
||||
'before:shadow',
|
||||
// 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(theme(borderRadius.lg)-1px)]',
|
||||
// Inner highlight shadow
|
||||
'after:shadow-[shadow:inset_0_1px_theme(colors.white/15%)]',
|
||||
// White overlay on hover
|
||||
'after:data-[active]:bg-[--btn-hover-overlay] after:data-[hover]:bg-[--btn-hover-overlay]',
|
||||
// Dark mode: `after` layer expands to cover entire button
|
||||
'dark:after:-inset-px dark:after:rounded-lg',
|
||||
// Disabled
|
||||
'before:data-[disabled]:shadow-none after:data-[disabled]: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:theme(colors.zinc.500)] data-[active]:[--btn-icon:theme(colors.zinc.700)] data-[hover]:[--btn-icon:theme(colors.zinc.700)] dark:data-[active]:[--btn-icon:theme(colors.zinc.400)] dark:data-[hover]:[--btn-icon:theme(colors.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:theme(colors.zinc.500)] data-[active]:[--btn-icon:theme(colors.zinc.700)] data-[hover]:[--btn-icon:theme(colors.zinc.700)] dark:[--btn-icon:theme(colors.zinc.500)] dark:data-[active]:[--btn-icon:theme(colors.zinc.400)] dark:data-[hover]:[--btn-icon:theme(colors.zinc.400)]',
|
||||
],
|
||||
colors: {
|
||||
'dark/zinc': [
|
||||
'text-white [--btn-bg:theme(colors.zinc.900)] [--btn-border:theme(colors.zinc.950/90%)] [--btn-hover-overlay:theme(colors.white/10%)]',
|
||||
'dark:text-white dark:[--btn-bg:theme(colors.zinc.600)] dark:[--btn-hover-overlay:theme(colors.white/5%)]',
|
||||
'[--btn-icon:theme(colors.zinc.400)] data-[active]:[--btn-icon:theme(colors.zinc.300)] data-[hover]:[--btn-icon:theme(colors.zinc.300)]',
|
||||
],
|
||||
light: [
|
||||
'text-zinc-950 [--btn-bg:white] [--btn-border:theme(colors.zinc.950/10%)] [--btn-hover-overlay:theme(colors.zinc.950/2.5%)] data-[active]:[--btn-border:theme(colors.zinc.950/15%)] data-[hover]:[--btn-border:theme(colors.zinc.950/15%)]',
|
||||
'dark:text-white dark:[--btn-hover-overlay:theme(colors.white/5%)] dark:[--btn-bg:theme(colors.zinc.800)]',
|
||||
'[--btn-icon:theme(colors.zinc.500)] data-[active]:[--btn-icon:theme(colors.zinc.700)] data-[hover]:[--btn-icon:theme(colors.zinc.700)] dark:[--btn-icon:theme(colors.zinc.500)] dark:data-[active]:[--btn-icon:theme(colors.zinc.400)] dark:data-[hover]:[--btn-icon:theme(colors.zinc.400)]',
|
||||
],
|
||||
'dark/white': [
|
||||
'text-white [--btn-bg:theme(colors.zinc.900)] [--btn-border:theme(colors.zinc.950/90%)] [--btn-hover-overlay:theme(colors.white/10%)]',
|
||||
'dark:text-zinc-950 dark:[--btn-bg:white] dark:[--btn-hover-overlay:theme(colors.zinc.950/5%)]',
|
||||
'[--btn-icon:theme(colors.zinc.400)] data-[active]:[--btn-icon:theme(colors.zinc.300)] data-[hover]:[--btn-icon:theme(colors.zinc.300)] dark:[--btn-icon:theme(colors.zinc.500)] dark:data-[active]:[--btn-icon:theme(colors.zinc.400)] dark:data-[hover]:[--btn-icon:theme(colors.zinc.400)]',
|
||||
],
|
||||
dark: [
|
||||
'text-white [--btn-bg:theme(colors.zinc.900)] [--btn-border:theme(colors.zinc.950/90%)] [--btn-hover-overlay:theme(colors.white/10%)]',
|
||||
'dark:[--btn-hover-overlay:theme(colors.white/5%)] dark:[--btn-bg:theme(colors.zinc.800)]',
|
||||
'[--btn-icon:theme(colors.zinc.400)] data-[active]:[--btn-icon:theme(colors.zinc.300)] data-[hover]:[--btn-icon:theme(colors.zinc.300)]',
|
||||
],
|
||||
white: [
|
||||
'text-zinc-950 [--btn-bg:white] [--btn-border:theme(colors.zinc.950/10%)] [--btn-hover-overlay:theme(colors.zinc.950/2.5%)] data-[active]:[--btn-border:theme(colors.zinc.950/15%)] data-[hover]:[--btn-border:theme(colors.zinc.950/15%)]',
|
||||
'dark:[--btn-hover-overlay:theme(colors.zinc.950/5%)]',
|
||||
'[--btn-icon:theme(colors.zinc.400)] data-[active]:[--btn-icon:theme(colors.zinc.500)] data-[hover]:[--btn-icon:theme(colors.zinc.500)]',
|
||||
],
|
||||
zinc: [
|
||||
'text-white [--btn-hover-overlay:theme(colors.white/10%)] [--btn-bg:theme(colors.zinc.600)] [--btn-border:theme(colors.zinc.700/90%)]',
|
||||
'dark:[--btn-hover-overlay:theme(colors.white/5%)]',
|
||||
'[--btn-icon:theme(colors.zinc.400)] data-[active]:[--btn-icon:theme(colors.zinc.300)] data-[hover]:[--btn-icon:theme(colors.zinc.300)]',
|
||||
],
|
||||
indigo: [
|
||||
'text-white [--btn-hover-overlay:theme(colors.white/10%)] [--btn-bg:theme(colors.indigo.500)] [--btn-border:theme(colors.indigo.600/90%)]',
|
||||
'[--btn-icon:theme(colors.indigo.300)] data-[active]:[--btn-icon:theme(colors.indigo.200)] data-[hover]:[--btn-icon:theme(colors.indigo.200)]',
|
||||
],
|
||||
cyan: [
|
||||
'text-cyan-950 [--btn-bg:theme(colors.cyan.300)] [--btn-border:theme(colors.cyan.400/80%)] [--btn-hover-overlay:theme(colors.white/25%)]',
|
||||
'[--btn-icon:theme(colors.cyan.500)]',
|
||||
],
|
||||
red: [
|
||||
'text-white [--btn-hover-overlay:theme(colors.white/10%)] [--btn-bg:theme(colors.red.600)] [--btn-border:theme(colors.red.700/90%)]',
|
||||
'[--btn-icon:theme(colors.red.300)] data-[active]:[--btn-icon:theme(colors.red.200)] data-[hover]:[--btn-icon:theme(colors.red.200)]',
|
||||
],
|
||||
orange: [
|
||||
'text-white [--btn-hover-overlay:theme(colors.white/10%)] [--btn-bg:theme(colors.orange.500)] [--btn-border:theme(colors.orange.600/90%)]',
|
||||
'[--btn-icon:theme(colors.orange.300)] data-[active]:[--btn-icon:theme(colors.orange.200)] data-[hover]:[--btn-icon:theme(colors.orange.200)]',
|
||||
],
|
||||
amber: [
|
||||
'text-amber-950 [--btn-hover-overlay:theme(colors.white/25%)] [--btn-bg:theme(colors.amber.400)] [--btn-border:theme(colors.amber.500/80%)]',
|
||||
'[--btn-icon:theme(colors.amber.600)]',
|
||||
],
|
||||
yellow: [
|
||||
'text-yellow-950 [--btn-hover-overlay:theme(colors.white/25%)] [--btn-bg:theme(colors.yellow.300)] [--btn-border:theme(colors.yellow.400/80%)]',
|
||||
'[--btn-icon:theme(colors.yellow.600)] data-[active]:[--btn-icon:theme(colors.yellow.700)] data-[hover]:[--btn-icon:theme(colors.yellow.700)]',
|
||||
],
|
||||
lime: [
|
||||
'text-lime-950 [--btn-hover-overlay:theme(colors.white/25%)] [--btn-bg:theme(colors.lime.300)] [--btn-border:theme(colors.lime.400/80%)]',
|
||||
'[--btn-icon:theme(colors.lime.600)] data-[active]:[--btn-icon:theme(colors.lime.700)] data-[hover]:[--btn-icon:theme(colors.lime.700)]',
|
||||
],
|
||||
green: [
|
||||
'text-white [--btn-hover-overlay:theme(colors.white/10%)] [--btn-bg:theme(colors.green.600)] [--btn-border:theme(colors.green.700/90%)]',
|
||||
'[--btn-icon:theme(colors.white/60%)] data-[active]:[--btn-icon:theme(colors.white/80%)] data-[hover]:[--btn-icon:theme(colors.white/80%)]',
|
||||
],
|
||||
emerald: [
|
||||
'text-white [--btn-hover-overlay:theme(colors.white/10%)] [--btn-bg:theme(colors.emerald.600)] [--btn-border:theme(colors.emerald.700/90%)]',
|
||||
'[--btn-icon:theme(colors.white/60%)] data-[active]:[--btn-icon:theme(colors.white/80%)] data-[hover]:[--btn-icon:theme(colors.white/80%)]',
|
||||
],
|
||||
teal: [
|
||||
'text-white [--btn-hover-overlay:theme(colors.white/10%)] [--btn-bg:theme(colors.teal.600)] [--btn-border:theme(colors.teal.700/90%)]',
|
||||
'[--btn-icon:theme(colors.white/60%)] data-[active]:[--btn-icon:theme(colors.white/80%)] data-[hover]:[--btn-icon:theme(colors.white/80%)]',
|
||||
],
|
||||
sky: [
|
||||
'text-white [--btn-hover-overlay:theme(colors.white/10%)] [--btn-bg:theme(colors.sky.500)] [--btn-border:theme(colors.sky.600/80%)]',
|
||||
'[--btn-icon:theme(colors.white/60%)] data-[active]:[--btn-icon:theme(colors.white/80%)] data-[hover]:[--btn-icon:theme(colors.white/80%)]',
|
||||
],
|
||||
blue: [
|
||||
'text-white [--btn-hover-overlay:theme(colors.white/10%)] [--btn-bg:theme(colors.blue.600)] [--btn-border:theme(colors.blue.700/90%)]',
|
||||
'[--btn-icon:theme(colors.blue.400)] data-[active]:[--btn-icon:theme(colors.blue.300)] data-[hover]:[--btn-icon:theme(colors.blue.300)]',
|
||||
],
|
||||
violet: [
|
||||
'text-white [--btn-hover-overlay:theme(colors.white/10%)] [--btn-bg:theme(colors.violet.500)] [--btn-border:theme(colors.violet.600/90%)]',
|
||||
'[--btn-icon:theme(colors.violet.300)] data-[active]:[--btn-icon:theme(colors.violet.200)] data-[hover]:[--btn-icon:theme(colors.violet.200)]',
|
||||
],
|
||||
purple: [
|
||||
'text-white [--btn-hover-overlay:theme(colors.white/10%)] [--btn-bg:theme(colors.purple.500)] [--btn-border:theme(colors.purple.600/90%)]',
|
||||
'[--btn-icon:theme(colors.purple.300)] data-[active]:[--btn-icon:theme(colors.purple.200)] data-[hover]:[--btn-icon:theme(colors.purple.200)]',
|
||||
],
|
||||
fuchsia: [
|
||||
'text-white [--btn-hover-overlay:theme(colors.white/10%)] [--btn-bg:theme(colors.fuchsia.500)] [--btn-border:theme(colors.fuchsia.600/90%)]',
|
||||
'[--btn-icon:theme(colors.fuchsia.300)] data-[active]:[--btn-icon:theme(colors.fuchsia.200)] data-[hover]:[--btn-icon:theme(colors.fuchsia.200)]',
|
||||
],
|
||||
pink: [
|
||||
'text-white [--btn-hover-overlay:theme(colors.white/10%)] [--btn-bg:theme(colors.pink.500)] [--btn-border:theme(colors.pink.600/90%)]',
|
||||
'[--btn-icon:theme(colors.pink.300)] data-[active]:[--btn-icon:theme(colors.pink.200)] data-[hover]:[--btn-icon:theme(colors.pink.200)]',
|
||||
],
|
||||
rose: [
|
||||
'text-white [--btn-hover-overlay:theme(colors.white/10%)] [--btn-bg:theme(colors.rose.500)] [--btn-border:theme(colors.rose.600/90%)]',
|
||||
'[--btn-icon:theme(colors.rose.300)] data-[active]:[--btn-icon:theme(colors.rose.200)] data-[hover]:[--btn-icon:theme(colors.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, '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 left-1/2 top-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/ui/checkbox.tsx
Normal file
157
src/components/ui/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 [&_[data-slot=label]]:has-[[data-slot=description]]:font-medium'
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function CheckboxField({
|
||||
className,
|
||||
...props
|
||||
}: { className?: string } & Omit<Headless.FieldProps, 'className'>) {
|
||||
return (
|
||||
<Headless.Field
|
||||
data-slot="field"
|
||||
{...props}
|
||||
className={clsx(
|
||||
className,
|
||||
// Base layout
|
||||
'grid grid-cols-[1.125rem_1fr] items-center 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]]:justify-self-center',
|
||||
// Label layout
|
||||
'[&>[data-slot=label]]:col-start-2 [&>[data-slot=label]]:row-start-1 [&>[data-slot=label]]:justify-self-start',
|
||||
// Description layout
|
||||
'[&>[data-slot=description]]:col-start-2 [&>[data-slot=description]]:row-start-2',
|
||||
// With description
|
||||
'[&_[data-slot=label]]:has-[[data-slot=description]]: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',
|
||||
// Background color when checked
|
||||
'before:group-data-[checked]: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-[checked]:group-data-[hover]: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-[checked]:group-data-[hover]: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(colors.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:theme(colors.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:theme(colors.white/50%)] dark:group-data-[disabled]:group-data-[checked]: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:theme(colors.white)] [--checkbox-checked-bg:theme(colors.zinc.900)] [--checkbox-checked-border:theme(colors.zinc.950/90%)]',
|
||||
'dark:[--checkbox-checked-bg:theme(colors.zinc.600)]',
|
||||
],
|
||||
'dark/white': [
|
||||
'[--checkbox-check:theme(colors.white)] [--checkbox-checked-bg:theme(colors.zinc.900)] [--checkbox-checked-border:theme(colors.zinc.950/90%)]',
|
||||
'dark:[--checkbox-check:theme(colors.zinc.900)] dark:[--checkbox-checked-bg:theme(colors.white)] dark:[--checkbox-checked-border:theme(colors.zinc.950/15%)]',
|
||||
],
|
||||
white:
|
||||
'[--checkbox-check:theme(colors.zinc.900)] [--checkbox-checked-bg:theme(colors.white)] [--checkbox-checked-border:theme(colors.zinc.950/15%)]',
|
||||
dark: '[--checkbox-check:theme(colors.white)] [--checkbox-checked-bg:theme(colors.zinc.900)] [--checkbox-checked-border:theme(colors.zinc.950/90%)]',
|
||||
zinc: '[--checkbox-check:theme(colors.white)] [--checkbox-checked-bg:theme(colors.zinc.600)] [--checkbox-checked-border:theme(colors.zinc.700/90%)]',
|
||||
red: '[--checkbox-check:theme(colors.white)] [--checkbox-checked-bg:theme(colors.red.600)] [--checkbox-checked-border:theme(colors.red.700/90%)]',
|
||||
orange:
|
||||
'[--checkbox-check:theme(colors.white)] [--checkbox-checked-bg:theme(colors.orange.500)] [--checkbox-checked-border:theme(colors.orange.600/90%)]',
|
||||
amber:
|
||||
'[--checkbox-check:theme(colors.amber.950)] [--checkbox-checked-bg:theme(colors.amber.400)] [--checkbox-checked-border:theme(colors.amber.500/80%)]',
|
||||
yellow:
|
||||
'[--checkbox-check:theme(colors.yellow.950)] [--checkbox-checked-bg:theme(colors.yellow.300)] [--checkbox-checked-border:theme(colors.yellow.400/80%)]',
|
||||
lime: '[--checkbox-check:theme(colors.lime.950)] [--checkbox-checked-bg:theme(colors.lime.300)] [--checkbox-checked-border:theme(colors.lime.400/80%)]',
|
||||
green:
|
||||
'[--checkbox-check:theme(colors.white)] [--checkbox-checked-bg:theme(colors.green.600)] [--checkbox-checked-border:theme(colors.green.700/90%)]',
|
||||
emerald:
|
||||
'[--checkbox-check:theme(colors.white)] [--checkbox-checked-bg:theme(colors.emerald.600)] [--checkbox-checked-border:theme(colors.emerald.700/90%)]',
|
||||
teal: '[--checkbox-check:theme(colors.white)] [--checkbox-checked-bg:theme(colors.teal.600)] [--checkbox-checked-border:theme(colors.teal.700/90%)]',
|
||||
cyan: '[--checkbox-check:theme(colors.cyan.950)] [--checkbox-checked-bg:theme(colors.cyan.300)] [--checkbox-checked-border:theme(colors.cyan.400/80%)]',
|
||||
sky: '[--checkbox-check:theme(colors.white)] [--checkbox-checked-bg:theme(colors.sky.500)] [--checkbox-checked-border:theme(colors.sky.600/80%)]',
|
||||
blue: '[--checkbox-check:theme(colors.white)] [--checkbox-checked-bg:theme(colors.blue.600)] [--checkbox-checked-border:theme(colors.blue.700/90%)]',
|
||||
indigo:
|
||||
'[--checkbox-check:theme(colors.white)] [--checkbox-checked-bg:theme(colors.indigo.500)] [--checkbox-checked-border:theme(colors.indigo.600/90%)]',
|
||||
violet:
|
||||
'[--checkbox-check:theme(colors.white)] [--checkbox-checked-bg:theme(colors.violet.500)] [--checkbox-checked-border:theme(colors.violet.600/90%)]',
|
||||
purple:
|
||||
'[--checkbox-check:theme(colors.white)] [--checkbox-checked-bg:theme(colors.purple.500)] [--checkbox-checked-border:theme(colors.purple.600/90%)]',
|
||||
fuchsia:
|
||||
'[--checkbox-check:theme(colors.white)] [--checkbox-checked-bg:theme(colors.fuchsia.500)] [--checkbox-checked-border:theme(colors.fuchsia.600/90%)]',
|
||||
pink: '[--checkbox-check:theme(colors.white)] [--checkbox-checked-bg:theme(colors.pink.500)] [--checkbox-checked-border:theme(colors.pink.600/90%)]',
|
||||
rose: '[--checkbox-check:theme(colors.white)] [--checkbox-checked-bg:theme(colors.rose.500)] [--checkbox-checked-border:theme(colors.rose.600/90%)]',
|
||||
}
|
||||
|
||||
type Color = keyof typeof colors
|
||||
|
||||
export function Checkbox({
|
||||
color = 'dark/zinc',
|
||||
className,
|
||||
...props
|
||||
}: {
|
||||
color?: Color
|
||||
className?: string
|
||||
} & Omit<Headless.CheckboxProps, 'className'>) {
|
||||
return (
|
||||
<Headless.Checkbox
|
||||
data-slot="control"
|
||||
{...props}
|
||||
className={clsx(className, 'group inline-flex focus:outline-none')}
|
||||
>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
37
src/components/ui/description-list.tsx
Normal file
37
src/components/ui/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%,theme(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,
|
||||
'pb-3 pt-1 text-zinc-950 sm:border-t sm:border-zinc-950/5 sm:py-3 dark:text-white dark:sm:border-white/5 sm:[&:nth-child(2)]:border-none'
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
86
src/components/ui/dialog.tsx
Normal file
86
src/components/ui/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,
|
||||
'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:theme(spacing.8)] sm:mb-auto sm:rounded-2xl dark:bg-zinc-900 dark:ring-white/10 forced-colors:outline',
|
||||
'transition duration-100 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, 'className'>) {
|
||||
return (
|
||||
<Headless.DialogTitle
|
||||
{...props}
|
||||
className={clsx(className, 'text-balance text-lg/6 font-semibold text-zinc-950 sm:text-base/6 dark:text-white')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: { className?: string } & Omit<Headless.DescriptionProps<typeof Text>, '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/ui/divider.tsx
Normal file
20
src/components/ui/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'
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
192
src/components/ui/dropdown.tsx
Normal file
192
src/components/ui/dropdown.tsx
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
'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, 'className'>) {
|
||||
return (
|
||||
<Headless.MenuItems
|
||||
{...props}
|
||||
transition
|
||||
anchor={anchor}
|
||||
className={clsx(
|
||||
className,
|
||||
// Anchor positioning
|
||||
'[--anchor-gap:theme(spacing.2)] [--anchor-padding:theme(spacing.1)] data-[anchor~=start]:[--anchor-offset:-6px] data-[anchor~=end]:[--anchor-offset:6px] sm:data-[anchor~=start]:[--anchor-offset:-4px] sm:data-[anchor~=end]:[--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-1 outline-transparent focus:outline-none',
|
||||
// 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-inset dark:ring-white/10',
|
||||
// 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-[closed]:data-[leave]:opacity-0 data-[leave]:duration-100 data-[leave]:ease-in'
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function DropdownItem({
|
||||
className,
|
||||
...props
|
||||
}: { className?: string } & (
|
||||
| Omit<React.ComponentPropsWithoutRef<typeof Link>, 'className'>
|
||||
| Omit<React.ComponentPropsWithoutRef<'button'>, 'className'>
|
||||
)) {
|
||||
let classes = clsx(
|
||||
className,
|
||||
// Base styles
|
||||
'group cursor-default rounded-lg px-3.5 py-2.5 focus:outline-none 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-slot=icon]]:data-[focus]: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]]:-ml-0.5 [&>[data-slot=icon]]:mr-2.5 [&>[data-slot=icon]]:size-5 sm:[&>[data-slot=icon]]:mr-2 [&>[data-slot=icon]]:sm:size-4',
|
||||
'[&>[data-slot=icon]]:text-zinc-500 [&>[data-slot=icon]]:data-[focus]:text-white [&>[data-slot=icon]]:dark:text-zinc-400 [&>[data-slot=icon]]:data-[focus]:dark:text-white',
|
||||
// Avatar
|
||||
'[&>[data-slot=avatar]]:-ml-1 [&>[data-slot=avatar]]:mr-2.5 [&>[data-slot=avatar]]:size-6 sm:[&>[data-slot=avatar]]:mr-2 sm:[&>[data-slot=avatar]]:size-5'
|
||||
)
|
||||
|
||||
return (
|
||||
<Headless.MenuItem>
|
||||
{'href' in props ? (
|
||||
<Link {...props} className={classes} />
|
||||
) : (
|
||||
<button type="button" {...props} className={classes} />
|
||||
)}
|
||||
</Headless.MenuItem>
|
||||
)
|
||||
}
|
||||
|
||||
export function DropdownHeader({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) {
|
||||
return <div {...props} className={clsx(className, 'col-span-5 px-3.5 pb-1 pt-2.5 sm:px-3')} />
|
||||
}
|
||||
|
||||
export function DropdownSection({
|
||||
className,
|
||||
...props
|
||||
}: { className?: string } & Omit<Headless.MenuSectionProps, '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, 'className'>) {
|
||||
return (
|
||||
<Headless.MenuHeading
|
||||
{...props}
|
||||
className={clsx(
|
||||
className,
|
||||
'col-span-full grid grid-cols-[1fr,auto] gap-x-12 px-3.5 pb-1 pt-2 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, '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, '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, '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'>, '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 capitalize text-zinc-400 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>
|
||||
)
|
||||
}
|
||||
88
src/components/ui/fieldset.tsx
Normal file
88
src/components/ui/fieldset.tsx
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
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, 'className'>) {
|
||||
return (
|
||||
<Headless.Fieldset
|
||||
{...props}
|
||||
className={clsx(className, '[&>*+[data-slot=control]]:mt-6 [&>[data-slot=text]]:mt-1')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function Legend({ className, ...props }: { className?: string } & Omit<Headless.LegendProps, '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, '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, 'className'>) {
|
||||
return (
|
||||
<Headless.Label
|
||||
data-slot="label"
|
||||
{...props}
|
||||
className={clsx(
|
||||
className,
|
||||
'select-none text-base/6 text-zinc-950 data-[disabled]:opacity-50 sm:text-sm/6 dark:text-white'
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function Description({
|
||||
className,
|
||||
...props
|
||||
}: { className?: string } & Omit<Headless.DescriptionProps, '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, '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/ui/heading.tsx
Normal file
27
src/components/ui/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/ui/input.tsx
Normal file
94
src/components/ui/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',
|
||||
'[&_input]:has-[[data-slot=icon]:first-child]:pl-10 [&_input]:has-[[data-slot=icon]:last-child]:pr-10 sm:[&_input]:has-[[data-slot=icon]:first-child]:pl-8 sm:[&_input]:has-[[data-slot=icon]:last-child]: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, '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(theme(borderRadius.lg)-1px)] before:bg-white before:shadow',
|
||||
// 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-inset after:ring-transparent sm:after:focus-within:ring-2 sm:after:focus-within:ring-blue-500',
|
||||
// Disabled state
|
||||
'has-[[data-disabled]]:opacity-50 before:has-[[data-disabled]]:bg-zinc-950/5 before:has-[[data-disabled]]:shadow-none',
|
||||
// Invalid state
|
||||
'before:has-[[data-invalid]]: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(theme(spacing[3.5])-1px)] py-[calc(theme(spacing[2.5])-1px)] sm:px-[calc(theme(spacing[3])-1px)] sm:py-[calc(theme(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-none',
|
||||
// Invalid state
|
||||
'data-[invalid]:border-red-500 data-[invalid]:data-[hover]:border-red-500 data-[invalid]:dark:border-red-500 data-[invalid]:data-[hover]:dark:border-red-500',
|
||||
// Disabled state
|
||||
'data-[disabled]:border-zinc-950/20 dark:data-[hover]:data-[disabled]:border-white/15 data-[disabled]:dark:border-white/15 data-[disabled]:dark:bg-white/[2.5%]',
|
||||
// System icons
|
||||
'dark:[color-scheme:dark]',
|
||||
])}
|
||||
/>
|
||||
</span>
|
||||
)
|
||||
})
|
||||
21
src/components/ui/link.tsx
Normal file
21
src/components/ui/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>
|
||||
)
|
||||
})
|
||||
174
src/components/ui/listbox.tsx
Normal file
174
src/components/ui/listbox.tsx
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
'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>, '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(theme(borderRadius.lg)-1px)] before:bg-white before:shadow',
|
||||
// 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-none',
|
||||
// Focus ring
|
||||
'after:pointer-events-none after:absolute after:inset-0 after:rounded-lg after:ring-inset after:ring-transparent after:data-[focus]:ring-2 after:data-[focus]:ring-blue-500',
|
||||
// Disabled state
|
||||
'data-[disabled]:opacity-50 before:data-[disabled]:bg-zinc-950/5 before:data-[disabled]: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(theme(spacing[2.5])-1px)] sm:py-[calc(theme(spacing[1.5])-1px)]',
|
||||
// Set minimum height for when no value is selected
|
||||
'min-h-11 sm:min-h-9',
|
||||
// Horizontal padding
|
||||
'pl-[calc(theme(spacing[3.5])-1px)] pr-[calc(theme(spacing.7)-1px)] sm:pl-[calc(theme(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-[invalid]:group-data-[hover]:border-red-500 group-data-[invalid]:dark:border-red-600 group-data-[invalid]:data-[hover]:dark:border-red-600',
|
||||
// Disabled state
|
||||
'group-data-[disabled]:border-zinc-950/20 group-data-[disabled]:opacity-100 group-data-[disabled]:dark:border-white/15 group-data-[disabled]:dark:bg-white/[2.5%] dark:data-[hover]:group-data-[disabled]: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:theme(spacing.4)] sm:[--anchor-offset:-1.375rem]',
|
||||
// Base styles
|
||||
'isolate w-max min-w-[calc(var(--button-width)+1.75rem)] select-none scroll-py-1 rounded-xl p-1',
|
||||
// Invisible border that is only visible in `forced-colors` mode for accessibility purposes
|
||||
'outline outline-1 outline-transparent focus:outline-none',
|
||||
// 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-inset dark:ring-white/10',
|
||||
// Transitions
|
||||
'transition-opacity duration-100 ease-in data-[transition]:pointer-events-none data-[closed]:data-[leave]:opacity-0'
|
||||
)}
|
||||
>
|
||||
{options}
|
||||
</Headless.ListboxOptions>
|
||||
</Headless.Listbox>
|
||||
)
|
||||
}
|
||||
|
||||
export function ListboxOption<T>({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: { className?: string; children?: React.ReactNode } & Omit<Headless.ListboxOptionProps<'div', T>, '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 [&>[data-slot=icon]]:group-data-[focus]/option:text-white [&>[data-slot=icon]]:dark:text-zinc-400',
|
||||
'forced-colors:[&>[data-slot=icon]]:text-[CanvasText] forced-colors:[&>[data-slot=icon]]:group-data-[focus]/option: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-[theme(spacing.5),1fr] items-baseline gap-x-2 rounded-lg py-2.5 pl-2 pr-3.5 sm:grid-cols-[theme(spacing.4),1fr] sm:py-1.5 sm:pl-1.5 sm:pr-3',
|
||||
// Typography
|
||||
'text-base/6 text-zinc-950 sm:text-sm/6 dark:text-white forced-colors:text-[CanvasText]',
|
||||
// Focus
|
||||
'outline-none 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 before:w-2 before:min-w-0 before:shrink group-data-[focus]/option:text-white dark:text-zinc-400'
|
||||
)}
|
||||
>
|
||||
<span className="flex-1 truncate">{children}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
96
src/components/ui/navbar.tsx
Normal file
96
src/components/ui/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, '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)
|
||||
'data-[slot=icon]:last:[&:not(:nth-child(2))]:*:ml-auto data-[slot=icon]:last:[&:not(:nth-child(2))]:*:size-5 sm:data-[slot=icon]:last:[&:not(:nth-child(2))]:*:size-4',
|
||||
// Avatar
|
||||
'data-[slot=avatar]:*:-m-0.5 data-[slot=avatar]:*:size-7 data-[slot=avatar]:*:[--avatar-radius:theme(borderRadius.DEFAULT)] data-[slot=avatar]:*:[--ring-opacity:10%] sm:data-[slot=avatar]:*:size-6',
|
||||
// Hover
|
||||
'data-[hover]:bg-zinc-950/5 data-[slot=icon]:*:data-[hover]:fill-zinc-950',
|
||||
// Active
|
||||
'data-[active]:bg-zinc-950/5 data-[slot=icon]:*:data-[active]:fill-zinc-950',
|
||||
// Dark mode
|
||||
'dark:text-white dark:data-[slot=icon]:*:fill-zinc-400',
|
||||
'dark:data-[hover]:bg-white/5 dark:data-[slot=icon]:*:data-[hover]:fill-white',
|
||||
'dark:data-[active]:bg-white/5 dark:data-[slot=icon]:*:data-[active]: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/ui/pagination.tsx
Normal file
101
src/components/ui/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] select-none text-center text-sm/6 font-semibold text-zinc-950 dark:text-white'
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
135
src/components/ui/radio.tsx
Normal file
135
src/components/ui/radio.tsx
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
import * as Headless from '@headlessui/react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
export function RadioGroup({
|
||||
className,
|
||||
...props
|
||||
}: { className?: string } & Omit<Headless.RadioGroupProps, '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 [&_[data-slot=label]]:has-[[data-slot=description]]:font-medium'
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function RadioField({ className, ...props }: { className?: string } & Omit<Headless.FieldProps, 'className'>) {
|
||||
return (
|
||||
<Headless.Field
|
||||
data-slot="field"
|
||||
{...props}
|
||||
className={clsx(
|
||||
className,
|
||||
// Base layout
|
||||
'grid grid-cols-[1.125rem_1fr] items-center 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]]:justify-self-center',
|
||||
// Label layout
|
||||
'[&>[data-slot=label]]:col-start-2 [&>[data-slot=label]]:row-start-1 [&>[data-slot=label]]:justify-self-start',
|
||||
// Description layout
|
||||
'[&>[data-slot=description]]:col-start-2 [&>[data-slot=description]]:row-start-2',
|
||||
// With description
|
||||
'[&_[data-slot=label]]:has-[[data-slot=description]]: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',
|
||||
// Background color when checked
|
||||
'before:group-data-[checked]: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-[checked]:group-data-[hover]: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-[checked]:group-data-[hover]: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(colors.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-[checked]:group-data-[hover]:[--radio-indicator:var(--radio-checked-indicator)] group-data-[hover]:[--radio-indicator:theme(colors.zinc.900/10%)]',
|
||||
// Indicator color (dark mode)
|
||||
'dark:group-data-[checked]:group-data-[hover]:[--radio-indicator:var(--radio-checked-indicator)] dark:group-data-[hover]:[--radio-indicator:theme(colors.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:theme(colors.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:theme(colors.white/50%)] dark:group-data-[disabled]:group-data-[checked]:after:hidden',
|
||||
]
|
||||
|
||||
const colors = {
|
||||
'dark/zinc': [
|
||||
'[--radio-checked-bg:theme(colors.zinc.900)] [--radio-checked-border:theme(colors.zinc.950/90%)] [--radio-checked-indicator:theme(colors.white)]',
|
||||
'dark:[--radio-checked-bg:theme(colors.zinc.600)]',
|
||||
],
|
||||
'dark/white': [
|
||||
'[--radio-checked-bg:theme(colors.zinc.900)] [--radio-checked-border:theme(colors.zinc.950/90%)] [--radio-checked-indicator:theme(colors.white)]',
|
||||
'dark:[--radio-checked-bg:theme(colors.white)] dark:[--radio-checked-border:theme(colors.zinc.950/15%)] dark:[--radio-checked-indicator:theme(colors.zinc.900)]',
|
||||
],
|
||||
white:
|
||||
'[--radio-checked-bg:theme(colors.white)] [--radio-checked-border:theme(colors.zinc.950/15%)] [--radio-checked-indicator:theme(colors.zinc.900)]',
|
||||
dark: '[--radio-checked-bg:theme(colors.zinc.900)] [--radio-checked-border:theme(colors.zinc.950/90%)] [--radio-checked-indicator:theme(colors.white)]',
|
||||
zinc: '[--radio-checked-indicator:theme(colors.white)] [--radio-checked-bg:theme(colors.zinc.600)] [--radio-checked-border:theme(colors.zinc.700/90%)]',
|
||||
red: '[--radio-checked-indicator:theme(colors.white)] [--radio-checked-bg:theme(colors.red.600)] [--radio-checked-border:theme(colors.red.700/90%)]',
|
||||
orange:
|
||||
'[--radio-checked-indicator:theme(colors.white)] [--radio-checked-bg:theme(colors.orange.500)] [--radio-checked-border:theme(colors.orange.600/90%)]',
|
||||
amber:
|
||||
'[--radio-checked-bg:theme(colors.amber.400)] [--radio-checked-border:theme(colors.amber.500/80%)] [--radio-checked-indicator:theme(colors.amber.950)]',
|
||||
yellow:
|
||||
'[--radio-checked-bg:theme(colors.yellow.300)] [--radio-checked-border:theme(colors.yellow.400/80%)] [--radio-checked-indicator:theme(colors.yellow.950)]',
|
||||
lime: '[--radio-checked-bg:theme(colors.lime.300)] [--radio-checked-border:theme(colors.lime.400/80%)] [--radio-checked-indicator:theme(colors.lime.950)]',
|
||||
green:
|
||||
'[--radio-checked-indicator:theme(colors.white)] [--radio-checked-bg:theme(colors.green.600)] [--radio-checked-border:theme(colors.green.700/90%)]',
|
||||
emerald:
|
||||
'[--radio-checked-indicator:theme(colors.white)] [--radio-checked-bg:theme(colors.emerald.600)] [--radio-checked-border:theme(colors.emerald.700/90%)]',
|
||||
teal: '[--radio-checked-indicator:theme(colors.white)] [--radio-checked-bg:theme(colors.teal.600)] [--radio-checked-border:theme(colors.teal.700/90%)]',
|
||||
cyan: '[--radio-checked-bg:theme(colors.cyan.300)] [--radio-checked-border:theme(colors.cyan.400/80%)] [--radio-checked-indicator:theme(colors.cyan.950)]',
|
||||
sky: '[--radio-checked-indicator:theme(colors.white)] [--radio-checked-bg:theme(colors.sky.500)] [--radio-checked-border:theme(colors.sky.600/80%)]',
|
||||
blue: '[--radio-checked-indicator:theme(colors.white)] [--radio-checked-bg:theme(colors.blue.600)] [--radio-checked-border:theme(colors.blue.700/90%)]',
|
||||
indigo:
|
||||
'[--radio-checked-indicator:theme(colors.white)] [--radio-checked-bg:theme(colors.indigo.500)] [--radio-checked-border:theme(colors.indigo.600/90%)]',
|
||||
violet:
|
||||
'[--radio-checked-indicator:theme(colors.white)] [--radio-checked-bg:theme(colors.violet.500)] [--radio-checked-border:theme(colors.violet.600/90%)]',
|
||||
purple:
|
||||
'[--radio-checked-indicator:theme(colors.white)] [--radio-checked-bg:theme(colors.purple.500)] [--radio-checked-border:theme(colors.purple.600/90%)]',
|
||||
fuchsia:
|
||||
'[--radio-checked-indicator:theme(colors.white)] [--radio-checked-bg:theme(colors.fuchsia.500)] [--radio-checked-border:theme(colors.fuchsia.600/90%)]',
|
||||
pink: '[--radio-checked-indicator:theme(colors.white)] [--radio-checked-bg:theme(colors.pink.500)] [--radio-checked-border:theme(colors.pink.600/90%)]',
|
||||
rose: '[--radio-checked-indicator:theme(colors.white)] [--radio-checked-bg:theme(colors.rose.500)] [--radio-checked-border:theme(colors.rose.600/90%)]',
|
||||
}
|
||||
|
||||
type Color = keyof typeof colors
|
||||
|
||||
export function Radio({
|
||||
color = 'dark/zinc',
|
||||
className,
|
||||
...props
|
||||
}: { color?: Color; className?: string } & Omit<Headless.RadioProps, 'className' | 'children'>) {
|
||||
return (
|
||||
<Headless.Radio data-slot="control" {...props} className={clsx(className, 'group inline-flex focus:outline-none')}>
|
||||
<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/ui/select.tsx
Normal file
68
src/components/ui/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, '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(theme(borderRadius.lg)-1px)] before:bg-white before:shadow',
|
||||
// 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-inset after:ring-transparent after:has-[[data-focus]]:ring-2 after:has-[[data-focus]]:ring-blue-500',
|
||||
// Disabled state
|
||||
'has-[[data-disabled]]:opacity-50 before:has-[[data-disabled]]:bg-zinc-950/5 before:has-[[data-disabled]]:shadow-none',
|
||||
])}
|
||||
>
|
||||
<Headless.Select
|
||||
ref={ref}
|
||||
multiple={multiple}
|
||||
{...props}
|
||||
className={clsx([
|
||||
// Basic layout
|
||||
'relative block w-full appearance-none rounded-lg py-[calc(theme(spacing[2.5])-1px)] sm:py-[calc(theme(spacing[1.5])-1px)]',
|
||||
// Horizontal padding
|
||||
multiple
|
||||
? 'px-[calc(theme(spacing[3.5])-1px)] sm:px-[calc(theme(spacing.3)-1px)]'
|
||||
: 'pl-[calc(theme(spacing[3.5])-1px)] pr-[calc(theme(spacing.10)-1px)] sm:pl-[calc(theme(spacing.3)-1px)] sm:pr-[calc(theme(spacing.9)-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-none',
|
||||
// Invalid state
|
||||
'data-[invalid]:border-red-500 data-[invalid]:data-[hover]:border-red-500 data-[invalid]:dark:border-red-600 data-[invalid]:data-[hover]:dark:border-red-600',
|
||||
// Disabled state
|
||||
'data-[disabled]:border-zinc-950/20 data-[disabled]:opacity-100 dark:data-[hover]:data-[disabled]:border-white/15 data-[disabled]:dark:border-white/15 data-[disabled]:dark:bg-white/[2.5%]',
|
||||
])}
|
||||
/>
|
||||
{!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/ui/sidebar-layout.tsx
Normal file
82
src/components/ui/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-[leave]:duration-200 data-[enter]:ease-out 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-sm 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:pl-64 lg:pr-2 lg:pt-2">
|
||||
<div className="grow p-6 lg:rounded-lg lg:bg-white lg:p-10 lg:shadow-sm 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>
|
||||
)
|
||||
}
|
||||
138
src/components/ui/sidebar.tsx
Normal file
138
src/components/ui/sidebar.tsx
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
'use client'
|
||||
|
||||
import * as Headless from '@headlessui/react'
|
||||
import clsx from 'clsx'
|
||||
import { LayoutGroup, motion } from 'framer-motion'
|
||||
import React, { Fragment, 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, 'className'>
|
||||
| Omit<React.ComponentPropsWithoutRef<typeof Link>, 'type' | '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)
|
||||
'data-[slot=icon]:last:*:ml-auto data-[slot=icon]:last:*:size-5 sm:data-[slot=icon]:last:*:size-4',
|
||||
// Avatar
|
||||
'data-[slot=avatar]:*:-m-0.5 data-[slot=avatar]:*:size-7 data-[slot=avatar]:*:[--ring-opacity:10%] sm:data-[slot=avatar]:*:size-6',
|
||||
// Hover
|
||||
'data-[hover]:bg-zinc-950/5 data-[slot=icon]:*:data-[hover]:fill-zinc-950',
|
||||
// Active
|
||||
'data-[active]:bg-zinc-950/5 data-[slot=icon]:*:data-[active]:fill-zinc-950',
|
||||
// Current
|
||||
'data-[slot=icon]:*:data-[current]:fill-zinc-950',
|
||||
// Dark mode
|
||||
'dark:text-white dark:data-[slot=icon]:*:fill-zinc-400',
|
||||
'dark:data-[hover]:bg-white/5 dark:data-[slot=icon]:*:data-[hover]:fill-white',
|
||||
'dark:data-[active]:bg-white/5 dark:data-[slot=icon]:*:data-[active]:fill-white',
|
||||
'dark:data-[slot=icon]:*:data-[current]: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={Fragment} ref={ref}>
|
||||
<Link className={classes} {...props} data-current={current ? 'true' : undefined}>
|
||||
<TouchTarget>{children}</TouchTarget>
|
||||
</Link>
|
||||
</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/ui/stacked-layout.tsx
Normal file
79
src/components/ui/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-[leave]:duration-200 data-[enter]:ease-out 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-sm 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-sm 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>
|
||||
)
|
||||
}
|
||||
192
src/components/ui/switch.tsx
Normal file
192
src/components/ui/switch.tsx
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
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 [&_[data-slot=label]]:has-[[data-slot=description]]:font-medium'
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function SwitchField({ className, ...props }: { className?: string } & Omit<Headless.FieldProps, 'className'>) {
|
||||
return (
|
||||
<Headless.Field
|
||||
data-slot="field"
|
||||
{...props}
|
||||
className={clsx(
|
||||
className,
|
||||
// Base layout
|
||||
'grid grid-cols-[1fr_auto] items-center gap-x-8 gap-y-1 sm:grid-cols-[1fr_auto]',
|
||||
// Control layout
|
||||
'[&>[data-slot=control]]:col-start-2 [&>[data-slot=control]]:self-center',
|
||||
// Label layout
|
||||
'[&>[data-slot=label]]:col-start-1 [&>[data-slot=label]]:row-start-1 [&>[data-slot=label]]:justify-self-start',
|
||||
// Description layout
|
||||
'[&>[data-slot=description]]:col-start-1 [&>[data-slot=description]]:row-start-2',
|
||||
// With description
|
||||
'[&_[data-slot=label]]:has-[[data-slot=description]]:font-medium'
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const colors = {
|
||||
'dark/zinc': [
|
||||
'[--switch-bg-ring:theme(colors.zinc.950/90%)] [--switch-bg:theme(colors.zinc.900)] dark:[--switch-bg-ring:transparent] dark:[--switch-bg:theme(colors.white/25%)]',
|
||||
'[--switch-ring:theme(colors.zinc.950/90%)] [--switch-shadow:theme(colors.black/10%)] [--switch:white] dark:[--switch-ring:theme(colors.zinc.700/90%)]',
|
||||
],
|
||||
'dark/white': [
|
||||
'[--switch-bg-ring:theme(colors.zinc.950/90%)] [--switch-bg:theme(colors.zinc.900)] dark:[--switch-bg-ring:transparent] dark:[--switch-bg:theme(colors.white)]',
|
||||
'[--switch-ring:theme(colors.zinc.950/90%)] [--switch-shadow:theme(colors.black/10%)] [--switch:white] dark:[--switch-ring:transparent] dark:[--switch:theme(colors.zinc.900)]',
|
||||
],
|
||||
dark: [
|
||||
'[--switch-bg-ring:theme(colors.zinc.950/90%)] [--switch-bg:theme(colors.zinc.900)] dark:[--switch-bg-ring:theme(colors.white/15%)]',
|
||||
'[--switch-ring:theme(colors.zinc.950/90%)] [--switch-shadow:theme(colors.black/10%)] [--switch:white]',
|
||||
],
|
||||
zinc: [
|
||||
'[--switch-bg-ring:theme(colors.zinc.700/90%)] [--switch-bg:theme(colors.zinc.600)] dark:[--switch-bg-ring:transparent]',
|
||||
'[--switch-shadow:theme(colors.black/10%)] [--switch:white] [--switch-ring:theme(colors.zinc.700/90%)]',
|
||||
],
|
||||
white: [
|
||||
'[--switch-bg-ring:theme(colors.black/15%)] [--switch-bg:white] dark:[--switch-bg-ring:transparent]',
|
||||
'[--switch-shadow:theme(colors.black/10%)] [--switch-ring:transparent] [--switch:theme(colors.zinc.950)]',
|
||||
],
|
||||
red: [
|
||||
'[--switch-bg-ring:theme(colors.red.700/90%)] [--switch-bg:theme(colors.red.600)] dark:[--switch-bg-ring:transparent]',
|
||||
'[--switch:white] [--switch-ring:theme(colors.red.700/90%)] [--switch-shadow:theme(colors.red.900/20%)]',
|
||||
],
|
||||
orange: [
|
||||
'[--switch-bg-ring:theme(colors.orange.600/90%)] [--switch-bg:theme(colors.orange.500)] dark:[--switch-bg-ring:transparent]',
|
||||
'[--switch:white] [--switch-ring:theme(colors.orange.600/90%)] [--switch-shadow:theme(colors.orange.900/20%)]',
|
||||
],
|
||||
amber: [
|
||||
'[--switch-bg-ring:theme(colors.amber.500/80%)] [--switch-bg:theme(colors.amber.400)] dark:[--switch-bg-ring:transparent]',
|
||||
'[--switch-ring:transparent] [--switch-shadow:transparent] [--switch:theme(colors.amber.950)]',
|
||||
],
|
||||
yellow: [
|
||||
'[--switch-bg-ring:theme(colors.yellow.400/80%)] [--switch-bg:theme(colors.yellow.300)] dark:[--switch-bg-ring:transparent]',
|
||||
'[--switch-ring:transparent] [--switch-shadow:transparent] [--switch:theme(colors.yellow.950)]',
|
||||
],
|
||||
lime: [
|
||||
'[--switch-bg-ring:theme(colors.lime.400/80%)] [--switch-bg:theme(colors.lime.300)] dark:[--switch-bg-ring:transparent]',
|
||||
'[--switch-ring:transparent] [--switch-shadow:transparent] [--switch:theme(colors.lime.950)]',
|
||||
],
|
||||
green: [
|
||||
'[--switch-bg-ring:theme(colors.green.700/90%)] [--switch-bg:theme(colors.green.600)] dark:[--switch-bg-ring:transparent]',
|
||||
'[--switch:white] [--switch-ring:theme(colors.green.700/90%)] [--switch-shadow:theme(colors.green.900/20%)]',
|
||||
],
|
||||
emerald: [
|
||||
'[--switch-bg-ring:theme(colors.emerald.600/90%)] [--switch-bg:theme(colors.emerald.500)] dark:[--switch-bg-ring:transparent]',
|
||||
'[--switch:white] [--switch-ring:theme(colors.emerald.600/90%)] [--switch-shadow:theme(colors.emerald.900/20%)]',
|
||||
],
|
||||
teal: [
|
||||
'[--switch-bg-ring:theme(colors.teal.700/90%)] [--switch-bg:theme(colors.teal.600)] dark:[--switch-bg-ring:transparent]',
|
||||
'[--switch:white] [--switch-ring:theme(colors.teal.700/90%)] [--switch-shadow:theme(colors.teal.900/20%)]',
|
||||
],
|
||||
cyan: [
|
||||
'[--switch-bg-ring:theme(colors.cyan.400/80%)] [--switch-bg:theme(colors.cyan.300)] dark:[--switch-bg-ring:transparent]',
|
||||
'[--switch-ring:transparent] [--switch-shadow:transparent] [--switch:theme(colors.cyan.950)]',
|
||||
],
|
||||
sky: [
|
||||
'[--switch-bg-ring:theme(colors.sky.600/80%)] [--switch-bg:theme(colors.sky.500)] dark:[--switch-bg-ring:transparent]',
|
||||
'[--switch:white] [--switch-ring:theme(colors.sky.600/80%)] [--switch-shadow:theme(colors.sky.900/20%)]',
|
||||
],
|
||||
blue: [
|
||||
'[--switch-bg-ring:theme(colors.blue.700/90%)] [--switch-bg:theme(colors.blue.600)] dark:[--switch-bg-ring:transparent]',
|
||||
'[--switch:white] [--switch-ring:theme(colors.blue.700/90%)] [--switch-shadow:theme(colors.blue.900/20%)]',
|
||||
],
|
||||
indigo: [
|
||||
'[--switch-bg-ring:theme(colors.indigo.600/90%)] [--switch-bg:theme(colors.indigo.500)] dark:[--switch-bg-ring:transparent]',
|
||||
'[--switch:white] [--switch-ring:theme(colors.indigo.600/90%)] [--switch-shadow:theme(colors.indigo.900/20%)]',
|
||||
],
|
||||
violet: [
|
||||
'[--switch-bg-ring:theme(colors.violet.600/90%)] [--switch-bg:theme(colors.violet.500)] dark:[--switch-bg-ring:transparent]',
|
||||
'[--switch:white] [--switch-ring:theme(colors.violet.600/90%)] [--switch-shadow:theme(colors.violet.900/20%)]',
|
||||
],
|
||||
purple: [
|
||||
'[--switch-bg-ring:theme(colors.purple.600/90%)] [--switch-bg:theme(colors.purple.500)] dark:[--switch-bg-ring:transparent]',
|
||||
'[--switch:white] [--switch-ring:theme(colors.purple.600/90%)] [--switch-shadow:theme(colors.purple.900/20%)]',
|
||||
],
|
||||
fuchsia: [
|
||||
'[--switch-bg-ring:theme(colors.fuchsia.600/90%)] [--switch-bg:theme(colors.fuchsia.500)] dark:[--switch-bg-ring:transparent]',
|
||||
'[--switch:white] [--switch-ring:theme(colors.fuchsia.600/90%)] [--switch-shadow:theme(colors.fuchsia.900/20%)]',
|
||||
],
|
||||
pink: [
|
||||
'[--switch-bg-ring:theme(colors.pink.600/90%)] [--switch-bg:theme(colors.pink.500)] dark:[--switch-bg-ring:transparent]',
|
||||
'[--switch:white] [--switch-ring:theme(colors.pink.600/90%)] [--switch-shadow:theme(colors.pink.900/20%)]',
|
||||
],
|
||||
rose: [
|
||||
'[--switch-bg-ring:theme(colors.rose.600/90%)] [--switch-bg:theme(colors.rose.500)] dark:[--switch-bg-ring:transparent]',
|
||||
'[--switch:white] [--switch-ring:theme(colors.rose.600/90%)] [--switch-shadow:theme(colors.rose.900/20%)]',
|
||||
],
|
||||
}
|
||||
|
||||
type Color = keyof typeof colors
|
||||
|
||||
export function Switch({
|
||||
color = 'dark/zinc',
|
||||
className,
|
||||
...props
|
||||
}: {
|
||||
color?: Color
|
||||
className?: string
|
||||
} & Omit<Headless.SwitchProps, '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-inset ring-black/5 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-none data-[focus]:outline data-[focus]:outline-2 data-[focus]:outline-offset-2 data-[focus]:outline-blue-500',
|
||||
// Hover
|
||||
'data-[hover]:data-[checked]:ring-[--switch-bg-ring] data-[hover]:ring-black/15',
|
||||
'dark:data-[hover]:data-[checked]:ring-[--switch-bg-ring] dark:data-[hover]:ring-white/25',
|
||||
// Disabled
|
||||
'data-[disabled]:bg-zinc-200 data-[disabled]:data-[checked]:bg-zinc-200 data-[disabled]:opacity-50 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 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-[disabled]:group-data-[checked]:bg-white group-data-[disabled]:group-data-[checked]:shadow group-data-[disabled]:group-data-[checked]:ring-black/5'
|
||||
)}
|
||||
/>
|
||||
</Headless.Switch>
|
||||
)
|
||||
}
|
||||
124
src/components/ui/table.tsx
Normal file
124
src/components/ui/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 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-[var(--gutter,theme(spacing.2))] last:pr-[var(--gutter,theme(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-[var(--gutter,theme(spacing.2))] last:pr-[var(--gutter,theme(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-none"
|
||||
/>
|
||||
)}
|
||||
{children}
|
||||
</td>
|
||||
)
|
||||
}
|
||||
40
src/components/ui/text.tsx
Normal file
40
src/components/ui/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 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/ui/textarea.tsx
Normal file
54
src/components/ui/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, '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(theme(borderRadius.lg)-1px)] before:bg-white before:shadow',
|
||||
// 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-inset after:ring-transparent sm:after:focus-within:ring-2 sm:after:focus-within:ring-blue-500',
|
||||
// Disabled state
|
||||
'has-[[data-disabled]]:opacity-50 before:has-[[data-disabled]]:bg-zinc-950/5 before:has-[[data-disabled]]:shadow-none',
|
||||
])}
|
||||
>
|
||||
<Headless.Textarea
|
||||
ref={ref}
|
||||
{...props}
|
||||
className={clsx([
|
||||
// Basic layout
|
||||
'relative block h-full w-full appearance-none rounded-lg px-[calc(theme(spacing[3.5])-1px)] py-[calc(theme(spacing[2.5])-1px)] sm:px-[calc(theme(spacing.3)-1px)] sm:py-[calc(theme(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-none',
|
||||
// Invalid state
|
||||
'data-[invalid]:border-red-500 data-[invalid]:data-[hover]:border-red-500 data-[invalid]:dark:border-red-600 data-[invalid]:data-[hover]:dark:border-red-600',
|
||||
// Disabled state
|
||||
'disabled:border-zinc-950/20 disabled:dark:border-white/15 disabled:dark:bg-white/[2.5%] dark:data-[hover]:disabled:border-white/15',
|
||||
// Resizable
|
||||
resizable ? 'resize-y' : 'resize-none',
|
||||
])}
|
||||
/>
|
||||
</span>
|
||||
)
|
||||
})
|
||||
|
|
@ -1,68 +1,3 @@
|
|||
:root {
|
||||
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
|
||||
color-scheme: light dark;
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
background-color: #242424;
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
a {
|
||||
font-weight: 500;
|
||||
color: #646cff;
|
||||
text-decoration: inherit;
|
||||
}
|
||||
a:hover {
|
||||
color: #535bf2;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
place-items: center;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 3.2em;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
button {
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
padding: 0.6em 1.2em;
|
||||
font-size: 1em;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
background-color: #1a1a1a;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.25s;
|
||||
}
|
||||
button:hover {
|
||||
border-color: #646cff;
|
||||
}
|
||||
button:focus,
|
||||
button:focus-visible {
|
||||
outline: 4px auto -webkit-focus-ring-color;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
color: #213547;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
a:hover {
|
||||
color: #747bff;
|
||||
}
|
||||
button {
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
}
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
|
|
|||
Reference in a new issue