Migrate frontend to TypeScript and extend user profile

Converted frontend codebase from JavaScript to TypeScript, including pages, components, and context. Added new layout and UI kit components. Updated backend user model and schemas to support profile fields (firstname, lastname, age, gender, height, weight, unit_preference) and added endpoints for reading/updating current user. Introduced food log listing endpoint and migration script for user table. Updated dependencies and build configs for TypeScript and Tailwind v4.
This commit is contained in:
Carlos Escalante
2026-01-18 19:01:00 -06:00
parent 184c8330a7
commit bd91eb4171
58 changed files with 4607 additions and 1414 deletions

View File

@@ -0,0 +1,14 @@
import { SidebarLayout } from '../catalyst/sidebar-layout'
import { AppSidebar } from './AppSidebar'
import { AppNavbar } from './AppNavbar'
export default function AppLayout({ children }: { children: React.ReactNode }) {
return (
<SidebarLayout
sidebar={<AppSidebar />}
navbar={<AppNavbar />}
>
{children}
</SidebarLayout>
)
}

View File

@@ -0,0 +1,13 @@
import { Navbar, NavbarItem, NavbarSection, NavbarSpacer } from '../catalyst/navbar'
export function AppNavbar() {
return (
<Navbar>
<NavbarSpacer />
<NavbarSection>
<NavbarItem href="/" className="font-bold text-primary">HealthyFit</NavbarItem>
</NavbarSection>
<NavbarSpacer />
</Navbar>
)
}

View File

@@ -0,0 +1,97 @@
import {
Sidebar,
SidebarBody,
SidebarFooter,
SidebarHeader,
SidebarItem,
SidebarLabel,
SidebarSection,
SidebarSpacer,
SidebarDivider,
} from '../catalyst/sidebar'
import {
LayoutDashboard,
Utensils,
Heart,
Calendar,
User,
Moon,
Sun,
LogOut,
} from 'lucide-react'
import { useLocation } from 'react-router-dom'
import { useTheme } from '../../context/ThemeContext'
import { useContext } from 'react'
import { AuthContext } from '../../context/AuthContext'
import { Avatar } from '../catalyst/avatar'
export function AppSidebar() {
const location = useLocation()
const { theme, toggleTheme } = useTheme()
const { user, logout } = useContext(AuthContext)
const navItems = [
{ to: "/", icon: LayoutDashboard, label: "Dashboard" },
{ to: "/nutrition", icon: Utensils, label: "Nutrition" },
{ to: "/health", icon: Heart, label: "Health" },
{ to: "/plans", icon: Calendar, label: "Plans" },
{ to: "/profile", icon: User, label: "Profile" },
]
return (
<Sidebar>
<SidebarHeader>
<SidebarItem href="/" className="mb-2">
<SidebarLabel className="text-2xl font-bold text-primary">HealthyFit</SidebarLabel>
</SidebarItem>
</SidebarHeader>
<SidebarBody>
<SidebarSection>
{navItems.map((item) => (
<SidebarItem
key={item.to}
href={item.to}
current={location.pathname === item.to}
>
<item.icon data-slot="icon" />
<SidebarLabel>{item.label}</SidebarLabel>
</SidebarItem>
))}
</SidebarSection>
<SidebarSpacer />
</SidebarBody>
<SidebarFooter>
<SidebarDivider />
<SidebarSection>
<SidebarItem onClick={toggleTheme}>
{theme === 'dark' ? <Moon data-slot="icon" /> : <Sun data-slot="icon" />}
<SidebarLabel>Appearance</SidebarLabel>
</SidebarItem>
</SidebarSection>
<div className="flex flex-col gap-2 p-2">
<div className="flex items-center gap-3">
<Avatar
initials={user?.firstname?.[0] || user?.email?.[0]?.toUpperCase() || 'U'}
className="bg-primary/20 text-primary size-10"
/>
<div className="min-w-0">
<div className="text-sm font-medium text-zinc-950 dark:text-white truncate">
{user?.firstname ? `${user.firstname} ${user.lastname || ''}` : user?.username}
</div>
<div className="text-xs text-zinc-500 dark:text-zinc-400 truncate">
{user?.email}
</div>
</div>
<button onClick={logout} className="ml-auto text-zinc-500 hover:text-red-500 transition-colors">
<LogOut size={16} />
</button>
</div>
</div>
</SidebarFooter>
</Sidebar>
)
}

View File

@@ -0,0 +1,17 @@
import React from 'react';
import Sidebar from './Sidebar';
const MainLayout = ({ children }) => {
return (
<div className="flex min-h-screen bg-base transition-colors duration-200">
<Sidebar />
<main className="flex-1 lg:ml-64 min-h-screen w-full">
<div className="container mx-auto px-4 py-8 lg:px-8 max-w-7xl animate-fade-in">
{children}
</div>
</main>
</div>
);
};
export default MainLayout;

View File

@@ -0,0 +1,124 @@
import React, { useState } from 'react';
import { NavLink } from 'react-router-dom';
import {
LayoutDashboard,
Utensils,
Heart,
Calendar,
User,
Moon,
Sun,
LogOut,
Menu,
X
} from 'lucide-react';
import { useTheme } from '../../context/ThemeContext';
import { useContext } from 'react';
import { AuthContext } from '../../context/AuthContext';
const Sidebar = () => {
const { theme, toggleTheme } = useTheme();
const { user, logout } = useContext(AuthContext);
const [isOpen, setIsOpen] = useState(false);
const navItems = [
{ to: "/", icon: LayoutDashboard, label: "Dashboard" },
{ to: "/nutrition", icon: Utensils, label: "Nutrition" },
{ to: "/health", icon: Heart, label: "Health" },
{ to: "/plans", icon: Calendar, label: "Plans" },
{ to: "/profile", icon: User, label: "Profile" },
];
const toggleSidebar = () => setIsOpen(!isOpen);
return (
<>
{/* Mobile Menu Button */}
<button
onClick={toggleSidebar}
className="lg:hidden fixed top-4 left-4 z-50 p-2 rounded-lg bg-surface text-content shadow-md hover:bg-opacity-80 transition-colors"
>
{isOpen ? <X size={24} /> : <Menu size={24} />}
</button>
{/* Overlay for mobile */}
{isOpen && (
<div
className="lg:hidden fixed inset-0 bg-black bg-opacity-50 z-40 backdrop-blur-sm"
onClick={() => setIsOpen(false)}
/>
)}
{/* Sidebar Container */}
<aside className={`
fixed top-0 left-0 z-40 h-screen w-64
bg-surface border-r border-border
transform transition-transform duration-300 ease-in-out
${isOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'}
`}>
<div className="flex flex-col h-full bg-surface text-content">
{/* Logo / Brand */}
<div className="h-16 flex items-center px-6 border-b border-border">
<span className="text-2xl font-bold text-primary">HealthyFit</span>
</div>
{/* Navigation */}
<nav className="flex-1 px-4 py-6 space-y-2 overflow-y-auto">
{navItems.map((item) => (
<NavLink
key={item.to}
to={item.to}
onClick={() => setIsOpen(false)}
className={({ isActive }) => `
flex items-center gap-3 px-4 py-3 rounded-xl transition-all duration-200 group
${isActive
? 'bg-primary text-white shadow-lg shadow-primary/20'
: 'text-content-muted hover:bg-black/5 dark:hover:bg-white/5 hover:text-primary'}
`}
>
<item.icon size={20} className="stroke-[2.5px] transition-transform group-hover:scale-110" />
<span className="font-medium">{item.label}</span>
</NavLink>
))}
</nav>
{/* Bottom Actions */}
<div className="p-4 border-t border-border space-y-4">
{/* Theme Toggle */}
<button
onClick={toggleTheme}
className="w-full flex items-center justify-between px-4 py-2 rounded-lg bg-black/5 dark:bg-white/5 text-content-muted hover:text-primary transition-colors"
>
<span className="text-sm font-medium">Appearance</span>
{theme === 'dark' ? <Moon size={18} /> : <Sun size={18} />}
</button>
{/* User Profile */}
<div className="flex items-center gap-3 px-2">
<div className="w-10 h-10 rounded-full bg-primary/20 flex items-center justify-center text-primary font-bold">
{user?.firstname?.[0] || user?.email?.[0]?.toUpperCase() || 'U'}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold truncate text-content">
{user?.firstname ? `${user.firstname} ${user.lastname || ''}` : user?.username}
</p>
<p className="text-xs text-content-muted truncate">{user?.email}</p>
</div>
<button
onClick={logout}
className="p-2 text-content-muted hover:text-red-500 transition-colors"
title="Logout"
>
<LogOut size={18} />
</button>
</div>
</div>
</div>
</aside>
</>
);
};
export default Sidebar;

View File

@@ -0,0 +1,87 @@
import * as Headless from '@headlessui/react'
import clsx from 'clsx'
import React, { forwardRef } from 'react'
import { TouchTarget } from './button'
import { Link } from './link'
type AvatarProps = {
src?: string | null
square?: boolean
initials?: string
alt?: string
className?: string
}
export function Avatar({
src = null,
square = false,
initials,
alt = '',
className,
...props
}: AvatarProps & React.ComponentPropsWithoutRef<'span'>) {
return (
<span
data-slot="avatar"
{...props}
className={clsx(
className,
// Basic layout
'inline-grid shrink-0 align-middle [--avatar-radius:20%] *:col-start-1 *:row-start-1',
'outline -outline-offset-1 outline-black/10 dark:outline-white/10',
// Border radius
square ? 'rounded-(--avatar-radius) *:rounded-(--avatar-radius)' : 'rounded-full *:rounded-full'
)}
>
{initials && (
<svg
className="size-full fill-current p-[5%] text-[48px] font-medium uppercase select-none"
viewBox="0 0 100 100"
aria-hidden={alt ? undefined : 'true'}
>
{alt && <title>{alt}</title>}
<text x="50%" y="50%" alignmentBaseline="middle" dominantBaseline="middle" textAnchor="middle" dy=".125em">
{initials}
</text>
</svg>
)}
{src && <img className="size-full" src={src} alt={alt} />}
</span>
)
}
export const AvatarButton = forwardRef(function AvatarButton(
{
src,
square = false,
initials,
alt,
className,
...props
}: AvatarProps &
(
| ({ href?: never } & Omit<Headless.ButtonProps, 'as' | 'className'>)
| ({ href: string } & Omit<React.ComponentPropsWithoutRef<typeof Link>, 'className'>)
),
ref: React.ForwardedRef<HTMLButtonElement>
) {
let classes = clsx(
className,
square ? 'rounded-[20%]' : 'rounded-full',
'relative inline-grid focus:not-data-focus:outline-hidden data-focus:outline-2 data-focus:outline-offset-2 data-focus:outline-blue-500'
)
return typeof props.href === 'string' ? (
<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>
)
})

View 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 } & (
| ({ href?: never } & Omit<Headless.ButtonProps, 'as' | 'className'>)
| ({ href: string } & Omit<React.ComponentPropsWithoutRef<typeof Link>, 'className'>)
),
ref: React.ForwardedRef<HTMLElement>
) {
let classes = clsx(
className,
'group relative inline-flex rounded-md focus:not-data-focus:outline-hidden data-focus:outline-2 data-focus:outline-offset-2 data-focus:outline-blue-500'
)
return typeof props.href === 'string' ? (
<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>
)
})

View File

@@ -0,0 +1,204 @@
import * as Headless from '@headlessui/react'
import clsx from 'clsx'
import React, { forwardRef } from 'react'
import { Link } from './link'
const styles = {
base: [
// Base
'relative isolate inline-flex items-baseline justify-center gap-x-2 rounded-lg border text-base/6 font-semibold',
// Sizing
'px-[calc(--spacing(3.5)-1px)] py-[calc(--spacing(2.5)-1px)] sm:px-[calc(--spacing(3)-1px)] sm:py-[calc(--spacing(1.5)-1px)] sm:text-sm/6',
// Focus
'focus:not-data-focus:outline-hidden data-focus:outline-2 data-focus:outline-offset-2 data-focus:outline-blue-500',
// Disabled
'data-disabled:opacity-50',
// Icon
'*:data-[slot=icon]:-mx-0.5 *:data-[slot=icon]:my-0.5 *:data-[slot=icon]:size-5 *:data-[slot=icon]:shrink-0 *:data-[slot=icon]:self-center *:data-[slot=icon]:text-(--btn-icon) sm:*:data-[slot=icon]:my-1 sm:*:data-[slot=icon]:size-4 forced-colors:[--btn-icon:ButtonText] forced-colors:data-hover:[--btn-icon:ButtonText]',
],
solid: [
// Optical border, implemented as the button background to avoid corner artifacts
'border-transparent bg-(--btn-border)',
// Dark mode: border is rendered on `after` so background is set to button background
'dark:bg-(--btn-bg)',
// Button background, implemented as foreground layer to stack on top of pseudo-border layer
'before:absolute before:inset-0 before:-z-10 before:rounded-[calc(var(--radius-lg)-1px)] before:bg-(--btn-bg)',
// Drop shadow, applied to the inset `before` layer so it blends with the border
'before:shadow-sm',
// Background color is moved to control and shadow is removed in dark mode so hide `before` pseudo
'dark:before:hidden',
// Dark mode: Subtle white outline is applied using a border
'dark:border-white/5',
// Shim/overlay, inset to match button foreground and used for hover state + highlight shadow
'after:absolute after:inset-0 after:-z-10 after:rounded-[calc(var(--radius-lg)-1px)]',
// Inner highlight shadow
'after:shadow-[inset_0_1px_--theme(--color-white/15%)]',
// White overlay on hover
'data-active:after:bg-(--btn-hover-overlay) data-hover:after:bg-(--btn-hover-overlay)',
// Dark mode: `after` layer expands to cover entire button
'dark:after:-inset-px dark:after:rounded-lg',
// Disabled
'data-disabled:before:shadow-none data-disabled:after:shadow-none',
],
outline: [
// Base
'border-zinc-950/10 text-zinc-950 data-active:bg-zinc-950/2.5 data-hover:bg-zinc-950/2.5',
// Dark mode
'dark:border-white/15 dark:text-white dark:[--btn-bg:transparent] dark:data-active:bg-white/5 dark:data-hover:bg-white/5',
// Icon
'[--btn-icon:var(--color-zinc-500)] data-active:[--btn-icon:var(--color-zinc-700)] data-hover:[--btn-icon:var(--color-zinc-700)] dark:data-active:[--btn-icon:var(--color-zinc-400)] dark:data-hover:[--btn-icon:var(--color-zinc-400)]',
],
plain: [
// Base
'border-transparent text-zinc-950 data-active:bg-zinc-950/5 data-hover:bg-zinc-950/5',
// Dark mode
'dark:text-white dark:data-active:bg-white/10 dark:data-hover:bg-white/10',
// Icon
'[--btn-icon:var(--color-zinc-500)] data-active:[--btn-icon:var(--color-zinc-700)] data-hover:[--btn-icon:var(--color-zinc-700)] dark:[--btn-icon:var(--color-zinc-500)] dark:data-active:[--btn-icon:var(--color-zinc-400)] dark:data-hover:[--btn-icon:var(--color-zinc-400)]',
],
colors: {
'dark/zinc': [
'text-white [--btn-bg:var(--color-zinc-900)] [--btn-border:var(--color-zinc-950)]/90 [--btn-hover-overlay:var(--color-white)]/10',
'dark:text-white dark:[--btn-bg:var(--color-zinc-600)] dark:[--btn-hover-overlay:var(--color-white)]/5',
'[--btn-icon:var(--color-zinc-400)] data-active:[--btn-icon:var(--color-zinc-300)] data-hover:[--btn-icon:var(--color-zinc-300)]',
],
light: [
'text-zinc-950 [--btn-bg:white] [--btn-border:var(--color-zinc-950)]/10 [--btn-hover-overlay:var(--color-zinc-950)]/2.5 data-active:[--btn-border:var(--color-zinc-950)]/15 data-hover:[--btn-border:var(--color-zinc-950)]/15',
'dark:text-white dark:[--btn-hover-overlay:var(--color-white)]/5 dark:[--btn-bg:var(--color-zinc-800)]',
'[--btn-icon:var(--color-zinc-500)] data-active:[--btn-icon:var(--color-zinc-700)] data-hover:[--btn-icon:var(--color-zinc-700)] dark:[--btn-icon:var(--color-zinc-500)] dark:data-active:[--btn-icon:var(--color-zinc-400)] dark:data-hover:[--btn-icon:var(--color-zinc-400)]',
],
'dark/white': [
'text-white [--btn-bg:var(--color-zinc-900)] [--btn-border:var(--color-zinc-950)]/90 [--btn-hover-overlay:var(--color-white)]/10',
'dark:text-zinc-950 dark:[--btn-bg:white] dark:[--btn-hover-overlay:var(--color-zinc-950)]/5',
'[--btn-icon:var(--color-zinc-400)] data-active:[--btn-icon:var(--color-zinc-300)] data-hover:[--btn-icon:var(--color-zinc-300)] dark:[--btn-icon:var(--color-zinc-500)] dark:data-active:[--btn-icon:var(--color-zinc-400)] dark:data-hover:[--btn-icon:var(--color-zinc-400)]',
],
dark: [
'text-white [--btn-bg:var(--color-zinc-900)] [--btn-border:var(--color-zinc-950)]/90 [--btn-hover-overlay:var(--color-white)]/10',
'dark:[--btn-hover-overlay:var(--color-white)]/5 dark:[--btn-bg:var(--color-zinc-800)]',
'[--btn-icon:var(--color-zinc-400)] data-active:[--btn-icon:var(--color-zinc-300)] data-hover:[--btn-icon:var(--color-zinc-300)]',
],
white: [
'text-zinc-950 [--btn-bg:white] [--btn-border:var(--color-zinc-950)]/10 [--btn-hover-overlay:var(--color-zinc-950)]/2.5 data-active:[--btn-border:var(--color-zinc-950)]/15 data-hover:[--btn-border:var(--color-zinc-950)]/15',
'dark:[--btn-hover-overlay:var(--color-zinc-950)]/5',
'[--btn-icon:var(--color-zinc-400)] data-active:[--btn-icon:var(--color-zinc-500)] data-hover:[--btn-icon:var(--color-zinc-500)]',
],
zinc: [
'text-white [--btn-hover-overlay:var(--color-white)]/10 [--btn-bg:var(--color-zinc-600)] [--btn-border:var(--color-zinc-700)]/90',
'dark:[--btn-hover-overlay:var(--color-white)]/5',
'[--btn-icon:var(--color-zinc-400)] data-active:[--btn-icon:var(--color-zinc-300)] data-hover:[--btn-icon:var(--color-zinc-300)]',
],
indigo: [
'text-white [--btn-hover-overlay:var(--color-white)]/10 [--btn-bg:var(--color-indigo-500)] [--btn-border:var(--color-indigo-600)]/90',
'[--btn-icon:var(--color-indigo-300)] data-active:[--btn-icon:var(--color-indigo-200)] data-hover:[--btn-icon:var(--color-indigo-200)]',
],
cyan: [
'text-cyan-950 [--btn-bg:var(--color-cyan-300)] [--btn-border:var(--color-cyan-400)]/80 [--btn-hover-overlay:var(--color-white)]/25',
'[--btn-icon:var(--color-cyan-500)]',
],
red: [
'text-white [--btn-hover-overlay:var(--color-white)]/10 [--btn-bg:var(--color-red-600)] [--btn-border:var(--color-red-700)]/90',
'[--btn-icon:var(--color-red-300)] data-active:[--btn-icon:var(--color-red-200)] data-hover:[--btn-icon:var(--color-red-200)]',
],
orange: [
'text-white [--btn-hover-overlay:var(--color-white)]/10 [--btn-bg:var(--color-orange-500)] [--btn-border:var(--color-orange-600)]/90',
'[--btn-icon:var(--color-orange-300)] data-active:[--btn-icon:var(--color-orange-200)] data-hover:[--btn-icon:var(--color-orange-200)]',
],
amber: [
'text-amber-950 [--btn-hover-overlay:var(--color-white)]/25 [--btn-bg:var(--color-amber-400)] [--btn-border:var(--color-amber-500)]/80',
'[--btn-icon:var(--color-amber-600)]',
],
yellow: [
'text-yellow-950 [--btn-hover-overlay:var(--color-white)]/25 [--btn-bg:var(--color-yellow-300)] [--btn-border:var(--color-yellow-400)]/80',
'[--btn-icon:var(--color-yellow-600)] data-active:[--btn-icon:var(--color-yellow-700)] data-hover:[--btn-icon:var(--color-yellow-700)]',
],
lime: [
'text-lime-950 [--btn-hover-overlay:var(--color-white)]/25 [--btn-bg:var(--color-lime-300)] [--btn-border:var(--color-lime-400)]/80',
'[--btn-icon:var(--color-lime-600)] data-active:[--btn-icon:var(--color-lime-700)] data-hover:[--btn-icon:var(--color-lime-700)]',
],
green: [
'text-white [--btn-hover-overlay:var(--color-white)]/10 [--btn-bg:var(--color-green-600)] [--btn-border:var(--color-green-700)]/90',
'[--btn-icon:var(--color-white)]/60 data-active:[--btn-icon:var(--color-white)]/80 data-hover:[--btn-icon:var(--color-white)]/80',
],
emerald: [
'text-white [--btn-hover-overlay:var(--color-white)]/10 [--btn-bg:var(--color-emerald-600)] [--btn-border:var(--color-emerald-700)]/90',
'[--btn-icon:var(--color-white)]/60 data-active:[--btn-icon:var(--color-white)]/80 data-hover:[--btn-icon:var(--color-white)]/80',
],
teal: [
'text-white [--btn-hover-overlay:var(--color-white)]/10 [--btn-bg:var(--color-teal-600)] [--btn-border:var(--color-teal-700)]/90',
'[--btn-icon:var(--color-white)]/60 data-active:[--btn-icon:var(--color-white)]/80 data-hover:[--btn-icon:var(--color-white)]/80',
],
sky: [
'text-white [--btn-hover-overlay:var(--color-white)]/10 [--btn-bg:var(--color-sky-500)] [--btn-border:var(--color-sky-600)]/80',
'[--btn-icon:var(--color-white)]/60 data-active:[--btn-icon:var(--color-white)]/80 data-hover:[--btn-icon:var(--color-white)]/80',
],
blue: [
'text-white [--btn-hover-overlay:var(--color-white)]/10 [--btn-bg:var(--color-blue-600)] [--btn-border:var(--color-blue-700)]/90',
'[--btn-icon:var(--color-blue-400)] data-active:[--btn-icon:var(--color-blue-300)] data-hover:[--btn-icon:var(--color-blue-300)]',
],
violet: [
'text-white [--btn-hover-overlay:var(--color-white)]/10 [--btn-bg:var(--color-violet-500)] [--btn-border:var(--color-violet-600)]/90',
'[--btn-icon:var(--color-violet-300)] data-active:[--btn-icon:var(--color-violet-200)] data-hover:[--btn-icon:var(--color-violet-200)]',
],
purple: [
'text-white [--btn-hover-overlay:var(--color-white)]/10 [--btn-bg:var(--color-purple-500)] [--btn-border:var(--color-purple-600)]/90',
'[--btn-icon:var(--color-purple-300)] data-active:[--btn-icon:var(--color-purple-200)] data-hover:[--btn-icon:var(--color-purple-200)]',
],
fuchsia: [
'text-white [--btn-hover-overlay:var(--color-white)]/10 [--btn-bg:var(--color-fuchsia-500)] [--btn-border:var(--color-fuchsia-600)]/90',
'[--btn-icon:var(--color-fuchsia-300)] data-active:[--btn-icon:var(--color-fuchsia-200)] data-hover:[--btn-icon:var(--color-fuchsia-200)]',
],
pink: [
'text-white [--btn-hover-overlay:var(--color-white)]/10 [--btn-bg:var(--color-pink-500)] [--btn-border:var(--color-pink-600)]/90',
'[--btn-icon:var(--color-pink-300)] data-active:[--btn-icon:var(--color-pink-200)] data-hover:[--btn-icon:var(--color-pink-200)]',
],
rose: [
'text-white [--btn-hover-overlay:var(--color-white)]/10 [--btn-bg:var(--color-rose-500)] [--btn-border:var(--color-rose-600)]/90',
'[--btn-icon:var(--color-rose-300)] data-active:[--btn-icon:var(--color-rose-200)] data-hover:[--btn-icon:var(--color-rose-200)]',
],
},
}
type ButtonProps = (
| { color?: keyof typeof styles.colors; outline?: never; plain?: never }
| { color?: never; outline: true; plain?: never }
| { color?: never; outline?: never; plain: true }
) & { className?: string; children: React.ReactNode } & (
| ({ href?: never } & Omit<Headless.ButtonProps, 'as' | 'className'>)
| ({ href: string } & 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 typeof props.href === 'string' ? (
<Link {...props} className={classes} ref={ref as React.ForwardedRef<HTMLAnchorElement>}>
<TouchTarget>{children}</TouchTarget>
</Link>
) : (
<Headless.Button {...props} className={clsx(classes, 'cursor-default')} ref={ref}>
<TouchTarget>{children}</TouchTarget>
</Headless.Button>
)
})
/**
* Expand the hit area to at least 44×44px on touch devices
*/
export function TouchTarget({ children }: { children: React.ReactNode }) {
return (
<>
<span
className="absolute top-1/2 left-1/2 size-[max(100%,2.75rem)] -translate-x-1/2 -translate-y-1/2 pointer-fine:hidden"
aria-hidden="true"
/>
{children}
</>
)
}

View File

@@ -0,0 +1,157 @@
import * as Headless from '@headlessui/react'
import clsx from 'clsx'
import type React from 'react'
export function CheckboxGroup({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) {
return (
<div
data-slot="control"
{...props}
className={clsx(
className,
// Basic groups
'space-y-3',
// With descriptions
'has-data-[slot=description]:space-y-6 has-data-[slot=description]:**:data-[slot=label]:font-medium'
)}
/>
)
}
export function CheckboxField({
className,
...props
}: { className?: string } & Omit<Headless.FieldProps, 'as' | 'className'>) {
return (
<Headless.Field
data-slot="field"
{...props}
className={clsx(
className,
// Base layout
'grid grid-cols-[1.125rem_1fr] gap-x-4 gap-y-1 sm:grid-cols-[1rem_1fr]',
// Control layout
'*:data-[slot=control]:col-start-1 *:data-[slot=control]:row-start-1 *:data-[slot=control]:mt-0.75 sm:*:data-[slot=control]:mt-1',
// Label layout
'*:data-[slot=label]:col-start-2 *:data-[slot=label]:row-start-1',
// Description layout
'*:data-[slot=description]:col-start-2 *:data-[slot=description]:row-start-2',
// With description
'has-data-[slot=description]:**:data-[slot=label]:font-medium'
)}
/>
)
}
const base = [
// Basic layout
'relative isolate flex size-4.5 items-center justify-center rounded-[0.3125rem] sm:size-4',
// Background color + shadow applied to inset pseudo element, so shadow blends with border in light mode
'before:absolute before:inset-0 before:-z-10 before:rounded-[calc(0.3125rem-1px)] before:bg-white before:shadow-sm',
// Background color when checked
'group-data-checked:before:bg-(--checkbox-checked-bg)',
// Background color is moved to control and shadow is removed in dark mode so hide `before` pseudo
'dark:before:hidden',
// Background color applied to control in dark mode
'dark:bg-white/5 dark:group-data-checked:bg-(--checkbox-checked-bg)',
// Border
'border border-zinc-950/15 group-data-checked:border-transparent group-data-hover:group-data-checked:border-transparent group-data-hover:border-zinc-950/30 group-data-checked:bg-(--checkbox-checked-border)',
'dark:border-white/15 dark:group-data-checked:border-white/5 dark:group-data-hover:group-data-checked:border-white/5 dark:group-data-hover:border-white/30',
// Inner highlight shadow
'after:absolute after:inset-0 after:rounded-[calc(0.3125rem-1px)] after:shadow-[inset_0_1px_--theme(--color-white/15%)]',
'dark:after:-inset-px dark:after:hidden dark:after:rounded-[0.3125rem] dark:group-data-checked:after:block',
// Focus ring
'group-data-focus:outline-2 group-data-focus:outline-offset-2 group-data-focus:outline-blue-500',
// Disabled state
'group-data-disabled:opacity-50',
'group-data-disabled:border-zinc-950/25 group-data-disabled:bg-zinc-950/5 group-data-disabled:[--checkbox-check:var(--color-zinc-950)]/50 group-data-disabled:before:bg-transparent',
'dark:group-data-disabled:border-white/20 dark:group-data-disabled:bg-white/2.5 dark:group-data-disabled:[--checkbox-check:var(--color-white)]/50 dark:group-data-checked:group-data-disabled:after:hidden',
// Forced colors mode
'forced-colors:[--checkbox-check:HighlightText] forced-colors:[--checkbox-checked-bg:Highlight] forced-colors:group-data-disabled:[--checkbox-check:Highlight]',
'dark:forced-colors:[--checkbox-check:HighlightText] dark:forced-colors:[--checkbox-checked-bg:Highlight] dark:forced-colors:group-data-disabled:[--checkbox-check:Highlight]',
]
const colors = {
'dark/zinc': [
'[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-zinc-900)] [--checkbox-checked-border:var(--color-zinc-950)]/90',
'dark:[--checkbox-checked-bg:var(--color-zinc-600)]',
],
'dark/white': [
'[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-zinc-900)] [--checkbox-checked-border:var(--color-zinc-950)]/90',
'dark:[--checkbox-check:var(--color-zinc-900)] dark:[--checkbox-checked-bg:var(--color-white)] dark:[--checkbox-checked-border:var(--color-zinc-950)]/15',
],
white:
'[--checkbox-check:var(--color-zinc-900)] [--checkbox-checked-bg:var(--color-white)] [--checkbox-checked-border:var(--color-zinc-950)]/15',
dark: '[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-zinc-900)] [--checkbox-checked-border:var(--color-zinc-950)]/90',
zinc: '[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-zinc-600)] [--checkbox-checked-border:var(--color-zinc-700)]/90',
red: '[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-red-600)] [--checkbox-checked-border:var(--color-red-700)]/90',
orange:
'[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-orange-500)] [--checkbox-checked-border:var(--color-orange-600)]/90',
amber:
'[--checkbox-check:var(--color-amber-950)] [--checkbox-checked-bg:var(--color-amber-400)] [--checkbox-checked-border:var(--color-amber-500)]/80',
yellow:
'[--checkbox-check:var(--color-yellow-950)] [--checkbox-checked-bg:var(--color-yellow-300)] [--checkbox-checked-border:var(--color-yellow-400)]/80',
lime: '[--checkbox-check:var(--color-lime-950)] [--checkbox-checked-bg:var(--color-lime-300)] [--checkbox-checked-border:var(--color-lime-400)]/80',
green:
'[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-green-600)] [--checkbox-checked-border:var(--color-green-700)]/90',
emerald:
'[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-emerald-600)] [--checkbox-checked-border:var(--color-emerald-700)]/90',
teal: '[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-teal-600)] [--checkbox-checked-border:var(--color-teal-700)]/90',
cyan: '[--checkbox-check:var(--color-cyan-950)] [--checkbox-checked-bg:var(--color-cyan-300)] [--checkbox-checked-border:var(--color-cyan-400)]/80',
sky: '[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-sky-500)] [--checkbox-checked-border:var(--color-sky-600)]/80',
blue: '[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-blue-600)] [--checkbox-checked-border:var(--color-blue-700)]/90',
indigo:
'[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-indigo-500)] [--checkbox-checked-border:var(--color-indigo-600)]/90',
violet:
'[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-violet-500)] [--checkbox-checked-border:var(--color-violet-600)]/90',
purple:
'[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-purple-500)] [--checkbox-checked-border:var(--color-purple-600)]/90',
fuchsia:
'[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-fuchsia-500)] [--checkbox-checked-border:var(--color-fuchsia-600)]/90',
pink: '[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-pink-500)] [--checkbox-checked-border:var(--color-pink-600)]/90',
rose: '[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-rose-500)] [--checkbox-checked-border:var(--color-rose-600)]/90',
}
type Color = keyof typeof colors
export function Checkbox({
color = 'dark/zinc',
className,
...props
}: {
color?: Color
className?: string
} & Omit<Headless.CheckboxProps, 'as' | 'className'>) {
return (
<Headless.Checkbox
data-slot="control"
{...props}
className={clsx(className, 'group inline-flex focus:outline-hidden')}
>
<span className={clsx([base, colors[color]])}>
<svg
className="size-4 stroke-(--checkbox-check) opacity-0 group-data-checked:opacity-100 sm:h-3.5 sm:w-3.5"
viewBox="0 0 14 14"
fill="none"
>
{/* Checkmark icon */}
<path
className="opacity-100 group-data-indeterminate:opacity-0"
d="M3 8L6 11L11 3.5"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
/>
{/* Indeterminate icon */}
<path
className="opacity-0 group-data-indeterminate:opacity-100"
d="M3 7H11"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</span>
</Headless.Checkbox>
)
}

View File

@@ -0,0 +1,86 @@
import * as Headless from '@headlessui/react'
import clsx from 'clsx'
import type React from 'react'
import { Text } from './text'
const sizes = {
xs: 'sm:max-w-xs',
sm: 'sm:max-w-sm',
md: 'sm:max-w-md',
lg: 'sm:max-w-lg',
xl: 'sm:max-w-xl',
'2xl': 'sm:max-w-2xl',
'3xl': 'sm:max-w-3xl',
'4xl': 'sm:max-w-4xl',
'5xl': 'sm:max-w-5xl',
}
export function Dialog({
size = 'lg',
className,
children,
...props
}: { size?: keyof typeof sizes; className?: string; children: React.ReactNode } & Omit<
Headless.DialogProps,
'as' | 'className'
>) {
return (
<Headless.Dialog {...props}>
<Headless.DialogBackdrop
transition
className="fixed inset-0 flex w-screen justify-center overflow-y-auto bg-zinc-950/25 px-2 py-2 transition duration-100 focus:outline-0 data-closed:opacity-0 data-enter:ease-out data-leave:ease-in sm:px-6 sm:py-8 lg:px-8 lg:py-16 dark:bg-zinc-950/50"
/>
<div className="fixed inset-0 w-screen overflow-y-auto pt-6 sm:pt-0">
<div className="grid min-h-full grid-rows-[1fr_auto] justify-items-center sm:grid-rows-[1fr_auto_3fr] sm:p-4">
<Headless.DialogPanel
transition
className={clsx(
className,
sizes[size],
'row-start-2 w-full min-w-0 rounded-t-3xl bg-white p-(--gutter) shadow-lg ring-1 ring-zinc-950/10 [--gutter:--spacing(8)] sm:mb-auto sm:rounded-2xl dark:bg-zinc-900 dark:ring-white/10 forced-colors:outline',
'transition duration-100 will-change-transform data-closed:translate-y-12 data-closed:opacity-0 data-enter:ease-out data-leave:ease-in sm:data-closed:translate-y-0 sm:data-closed:data-enter:scale-95'
)}
>
{children}
</Headless.DialogPanel>
</div>
</div>
</Headless.Dialog>
)
}
export function DialogTitle({
className,
...props
}: { className?: string } & Omit<Headless.DialogTitleProps, 'as' | 'className'>) {
return (
<Headless.DialogTitle
{...props}
className={clsx(className, 'text-lg/6 font-semibold text-balance text-zinc-950 sm:text-base/6 dark:text-white')}
/>
)
}
export function DialogDescription({
className,
...props
}: { className?: string } & Omit<Headless.DescriptionProps<typeof Text>, 'as' | 'className'>) {
return <Headless.Description as={Text} {...props} className={clsx(className, 'mt-2 text-pretty')} />
}
export function DialogBody({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) {
return <div {...props} className={clsx(className, 'mt-6')} />
}
export function DialogActions({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) {
return (
<div
{...props}
className={clsx(
className,
'mt-8 flex flex-col-reverse items-center justify-end gap-3 *:w-full sm:flex-row sm:*:w-auto'
)}
/>
)
}

View File

@@ -0,0 +1,91 @@
import * as Headless from '@headlessui/react'
import clsx from 'clsx'
import type React from 'react'
export function Fieldset({
className,
...props
}: { className?: string } & Omit<Headless.FieldsetProps, 'as' | 'className'>) {
return (
<Headless.Fieldset
{...props}
className={clsx(className, '*:data-[slot=text]:mt-1 [&>*+[data-slot=control]]:mt-6')}
/>
)
}
export function Legend({
className,
...props
}: { className?: string } & Omit<Headless.LegendProps, 'as' | 'className'>) {
return (
<Headless.Legend
data-slot="legend"
{...props}
className={clsx(
className,
'text-base/6 font-semibold text-zinc-950 data-disabled:opacity-50 sm:text-sm/6 dark:text-white'
)}
/>
)
}
export function FieldGroup({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) {
return <div data-slot="control" {...props} className={clsx(className, 'space-y-8')} />
}
export function Field({ className, ...props }: { className?: string } & Omit<Headless.FieldProps, 'as' | 'className'>) {
return (
<Headless.Field
{...props}
className={clsx(
className,
'[&>[data-slot=label]+[data-slot=control]]:mt-3',
'[&>[data-slot=label]+[data-slot=description]]:mt-1',
'[&>[data-slot=description]+[data-slot=control]]:mt-3',
'[&>[data-slot=control]+[data-slot=description]]:mt-3',
'[&>[data-slot=control]+[data-slot=error]]:mt-3',
'*:data-[slot=label]:font-medium'
)}
/>
)
}
export function Label({ className, ...props }: { className?: string } & Omit<Headless.LabelProps, 'as' | 'className'>) {
return (
<Headless.Label
data-slot="label"
{...props}
className={clsx(
className,
'text-base/6 text-zinc-950 select-none data-disabled:opacity-50 sm:text-sm/6 dark:text-white'
)}
/>
)
}
export function Description({
className,
...props
}: { className?: string } & Omit<Headless.DescriptionProps, 'as' | 'className'>) {
return (
<Headless.Description
data-slot="description"
{...props}
className={clsx(className, 'text-base/6 text-zinc-500 data-disabled:opacity-50 sm:text-sm/6 dark:text-zinc-400')}
/>
)
}
export function ErrorMessage({
className,
...props
}: { className?: string } & Omit<Headless.DescriptionProps, 'as' | 'className'>) {
return (
<Headless.Description
data-slot="error"
{...props}
className={clsx(className, 'text-base/6 text-red-600 data-disabled:opacity-50 sm:text-sm/6 dark:text-red-500')}
/>
)
}

View File

@@ -0,0 +1,28 @@
import clsx from 'clsx'
import React from 'react'
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')}
/>
)
}

View File

@@ -0,0 +1,92 @@
import * as Headless from '@headlessui/react'
import clsx from 'clsx'
import React, { forwardRef } from 'react'
export function InputGroup({ children }: React.ComponentPropsWithoutRef<'span'>) {
return (
<span
data-slot="control"
className={clsx(
'relative isolate block',
'has-[[data-slot=icon]:first-child]:[&_input]:pl-10 has-[[data-slot=icon]:last-child]:[&_input]:pr-10 sm:has-[[data-slot=icon]:first-child]:[&_input]:pl-8 sm:has-[[data-slot=icon]:last-child]:[&_input]:pr-8',
'*:data-[slot=icon]:pointer-events-none *:data-[slot=icon]:absolute *:data-[slot=icon]:top-3 *:data-[slot=icon]:z-10 *:data-[slot=icon]:size-5 sm:*:data-[slot=icon]:top-2.5 sm:*:data-[slot=icon]:size-4',
'[&>[data-slot=icon]:first-child]:left-3 sm:[&>[data-slot=icon]:first-child]:left-2.5 [&>[data-slot=icon]:last-child]:right-3 sm:[&>[data-slot=icon]:last-child]:right-2.5',
'*:data-[slot=icon]:text-zinc-500 dark:*:data-[slot=icon]:text-zinc-400'
)}
>
{children}
</span>
)
}
const dateTypes = ['date', 'datetime-local', 'month', 'time', 'week']
type DateType = (typeof dateTypes)[number]
export const Input = forwardRef(function Input(
{
className,
...props
}: {
className?: string
type?: 'email' | 'number' | 'password' | 'search' | 'tel' | 'text' | 'url' | DateType
} & Omit<Headless.InputProps, 'as' | 'className'>,
ref: React.ForwardedRef<HTMLInputElement>
) {
return (
<span
data-slot="control"
className={clsx([
className,
// Basic layout
'relative block w-full',
// Background color + shadow applied to inset pseudo element, so shadow blends with border in light mode
'before:absolute before:inset-px before:rounded-[calc(var(--radius-lg)-1px)] before:bg-white before:shadow-sm',
// Background color is moved to control and shadow is removed in dark mode so hide `before` pseudo
'dark:before:hidden',
// Focus ring
'after:pointer-events-none after:absolute after:inset-0 after:rounded-lg after:ring-transparent after:ring-inset sm:focus-within:after:ring-2 sm:focus-within:after:ring-blue-500',
// Disabled state
'has-data-disabled:opacity-50 has-data-disabled:before:bg-zinc-950/5 has-data-disabled:before:shadow-none',
])}
>
<Headless.Input
ref={ref}
{...props}
className={clsx([
// Date classes
props.type &&
dateTypes.includes(props.type) && [
'[&::-webkit-datetime-edit-fields-wrapper]:p-0',
'[&::-webkit-date-and-time-value]:min-h-[1.5em]',
'[&::-webkit-datetime-edit]:inline-flex',
'[&::-webkit-datetime-edit]:p-0',
'[&::-webkit-datetime-edit-year-field]:p-0',
'[&::-webkit-datetime-edit-month-field]:p-0',
'[&::-webkit-datetime-edit-day-field]:p-0',
'[&::-webkit-datetime-edit-hour-field]:p-0',
'[&::-webkit-datetime-edit-minute-field]:p-0',
'[&::-webkit-datetime-edit-second-field]:p-0',
'[&::-webkit-datetime-edit-millisecond-field]:p-0',
'[&::-webkit-datetime-edit-meridiem-field]:p-0',
],
// Basic layout
'relative block w-full appearance-none rounded-lg px-[calc(--spacing(3.5)-1px)] py-[calc(--spacing(2.5)-1px)] sm:px-[calc(--spacing(3)-1px)] sm:py-[calc(--spacing(1.5)-1px)]',
// Typography
'text-base/6 text-zinc-950 placeholder:text-zinc-500 sm:text-sm/6 dark:text-white',
// Border
'border border-zinc-950/10 data-hover:border-zinc-950/20 dark:border-white/10 dark:data-hover:border-white/20',
// Background color
'bg-transparent dark:bg-white/5',
// Hide default focus styles
'focus:outline-hidden',
// Invalid state
'data-invalid:border-red-500 data-invalid:data-hover:border-red-500 dark:data-invalid:border-red-600 dark:data-invalid:data-hover:border-red-600',
// Disabled state
'data-disabled:border-zinc-950/20 dark:data-disabled:border-white/15 dark:data-disabled:bg-white/2.5 dark:data-hover:data-disabled:border-white/15',
// System icons
'dark:scheme-dark',
])}
/>
</span>
)
})

View File

@@ -0,0 +1,18 @@
/**
* Updated to use react-router-dom
*/
import * as Headless from '@headlessui/react'
import { Link as RouterLink, LinkProps as RouterLinkProps } from 'react-router-dom'
import React, { forwardRef } from 'react'
export const Link = forwardRef(function Link(
props: { href: string } & Omit<RouterLinkProps, 'to'>,
ref: React.ForwardedRef<HTMLAnchorElement>
) {
const { href, ...rest } = props
return (
<Headless.DataInteractive>
<RouterLink to={href} {...rest} ref={ref} />
</Headless.DataInteractive>
)
})

View File

@@ -0,0 +1,96 @@
'use client'
import * as Headless from '@headlessui/react'
import clsx from 'clsx'
import { LayoutGroup, motion } from 'motion/react'
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 } & (
| ({ href?: never } & Omit<Headless.ButtonProps, 'as' | 'className'>)
| ({ href: string } & Omit<React.ComponentPropsWithoutRef<typeof Link>, 'className'>)
),
ref: React.ForwardedRef<HTMLAnchorElement | HTMLButtonElement>
) {
let classes = clsx(
// Base
'relative flex min-w-0 items-center gap-3 rounded-lg p-2 text-left text-base/6 font-medium text-zinc-950 sm:text-sm/5',
// Leading icon/icon-only
'*:data-[slot=icon]:size-6 *:data-[slot=icon]:shrink-0 *:data-[slot=icon]:fill-zinc-500 sm:*:data-[slot=icon]:size-5',
// Trailing icon (down chevron or similar)
'*:not-nth-2:last:data-[slot=icon]:ml-auto *:not-nth-2:last:data-[slot=icon]:size-5 sm:*:not-nth-2:last:data-[slot=icon]:size-4',
// Avatar
'*:data-[slot=avatar]:-m-0.5 *:data-[slot=avatar]:size-7 *:data-[slot=avatar]:[--avatar-radius:var(--radius-md)] sm:*:data-[slot=avatar]:size-6',
// Hover
'data-hover:bg-zinc-950/5 data-hover:*:data-[slot=icon]:fill-zinc-950',
// Active
'data-active:bg-zinc-950/5 data-active:*:data-[slot=icon]:fill-zinc-950',
// Dark mode
'dark:text-white dark:*:data-[slot=icon]:fill-zinc-400',
'dark:data-hover:bg-white/5 dark:data-hover:*:data-[slot=icon]:fill-white',
'dark:data-active:bg-white/5 dark:data-active:*:data-[slot=icon]:fill-white'
)
return (
<span className={clsx(className, 'relative')}>
{current && (
<motion.span
layoutId="current-indicator"
className="absolute inset-x-2 -bottom-2.5 h-0.5 rounded-full bg-zinc-950 dark:bg-white"
/>
)}
{typeof props.href === 'string' ? (
<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')} />
}

View File

@@ -0,0 +1,142 @@
import * as Headless from '@headlessui/react'
import clsx from 'clsx'
export function RadioGroup({
className,
...props
}: { className?: string } & Omit<Headless.RadioGroupProps, 'as' | 'className'>) {
return (
<Headless.RadioGroup
data-slot="control"
{...props}
className={clsx(
className,
// Basic groups
'space-y-3 **:data-[slot=label]:font-normal',
// With descriptions
'has-data-[slot=description]:space-y-6 has-data-[slot=description]:**:data-[slot=label]:font-medium'
)}
/>
)
}
export function RadioField({
className,
...props
}: { className?: string } & Omit<Headless.FieldProps, 'as' | 'className'>) {
return (
<Headless.Field
data-slot="field"
{...props}
className={clsx(
className,
// Base layout
'grid grid-cols-[1.125rem_1fr] gap-x-4 gap-y-1 sm:grid-cols-[1rem_1fr]',
// Control layout
'*:data-[slot=control]:col-start-1 *:data-[slot=control]:row-start-1 *:data-[slot=control]:mt-0.75 sm:*:data-[slot=control]:mt-1',
// Label layout
'*:data-[slot=label]:col-start-2 *:data-[slot=label]:row-start-1',
// Description layout
'*:data-[slot=description]:col-start-2 *:data-[slot=description]:row-start-2',
// With description
'has-data-[slot=description]:**:data-[slot=label]:font-medium'
)}
/>
)
}
const base = [
// Basic layout
'relative isolate flex size-4.75 shrink-0 rounded-full sm:size-4.25',
// Background color + shadow applied to inset pseudo element, so shadow blends with border in light mode
'before:absolute before:inset-0 before:-z-10 before:rounded-full before:bg-white before:shadow-sm',
// Background color when checked
'group-data-checked:before:bg-(--radio-checked-bg)',
// Background color is moved to control and shadow is removed in dark mode so hide `before` pseudo
'dark:before:hidden',
// Background color applied to control in dark mode
'dark:bg-white/5 dark:group-data-checked:bg-(--radio-checked-bg)',
// Border
'border border-zinc-950/15 group-data-checked:border-transparent group-data-hover:group-data-checked:border-transparent group-data-hover:border-zinc-950/30 group-data-checked:bg-(--radio-checked-border)',
'dark:border-white/15 dark:group-data-checked:border-white/5 dark:group-data-hover:group-data-checked:border-white/5 dark:group-data-hover:border-white/30',
// Inner highlight shadow
'after:absolute after:inset-0 after:rounded-full after:shadow-[inset_0_1px_--theme(--color-white/15%)]',
'dark:after:-inset-px dark:after:hidden dark:after:rounded-full dark:group-data-checked:after:block',
// Indicator color (light mode)
'[--radio-indicator:transparent] group-data-checked:[--radio-indicator:var(--radio-checked-indicator)] group-data-hover:group-data-checked:[--radio-indicator:var(--radio-checked-indicator)] group-data-hover:[--radio-indicator:var(--color-zinc-900)]/10',
// Indicator color (dark mode)
'dark:group-data-hover:group-data-checked:[--radio-indicator:var(--radio-checked-indicator)] dark:group-data-hover:[--radio-indicator:var(--color-zinc-700)]',
// Focus ring
'group-data-focus:outline group-data-focus:outline-2 group-data-focus:outline-offset-2 group-data-focus:outline-blue-500',
// Disabled state
'group-data-disabled:opacity-50',
'group-data-disabled:border-zinc-950/25 group-data-disabled:bg-zinc-950/5 group-data-disabled:[--radio-checked-indicator:var(--color-zinc-950)]/50 group-data-disabled:before:bg-transparent',
'dark:group-data-disabled:border-white/20 dark:group-data-disabled:bg-white/2.5 dark:group-data-disabled:[--radio-checked-indicator:var(--color-white)]/50 dark:group-data-checked:group-data-disabled:after:hidden',
]
const colors = {
'dark/zinc': [
'[--radio-checked-bg:var(--color-zinc-900)] [--radio-checked-border:var(--color-zinc-950)]/90 [--radio-checked-indicator:var(--color-white)]',
'dark:[--radio-checked-bg:var(--color-zinc-600)]',
],
'dark/white': [
'[--radio-checked-bg:var(--color-zinc-900)] [--radio-checked-border:var(--color-zinc-950)]/90 [--radio-checked-indicator:var(--color-white)]',
'dark:[--radio-checked-bg:var(--color-white)] dark:[--radio-checked-border:var(--color-zinc-950)]/15 dark:[--radio-checked-indicator:var(--color-zinc-900)]',
],
white:
'[--radio-checked-bg:var(--color-white)] [--radio-checked-border:var(--color-zinc-950)]/15 [--radio-checked-indicator:var(--color-zinc-900)]',
dark: '[--radio-checked-bg:var(--color-zinc-900)] [--radio-checked-border:var(--color-zinc-950)]/90 [--radio-checked-indicator:var(--color-white)]',
zinc: '[--radio-checked-indicator:var(--color-white)] [--radio-checked-bg:var(--color-zinc-600)] [--radio-checked-border:var(--color-zinc-700)]/90',
red: '[--radio-checked-indicator:var(--color-white)] [--radio-checked-bg:var(--color-red-600)] [--radio-checked-border:var(--color-red-700)]/90',
orange:
'[--radio-checked-indicator:var(--color-white)] [--radio-checked-bg:var(--color-orange-500)] [--radio-checked-border:var(--color-orange-600)]/90',
amber:
'[--radio-checked-bg:var(--color-amber-400)] [--radio-checked-border:var(--color-amber-500)]/80 [--radio-checked-indicator:var(--color-amber-950)]',
yellow:
'[--radio-checked-bg:var(--color-yellow-300)] [--radio-checked-border:var(--color-yellow-400)]/80 [--radio-checked-indicator:var(--color-yellow-950)]',
lime: '[--radio-checked-bg:var(--color-lime-300)] [--radio-checked-border:var(--color-lime-400)]/80 [--radio-checked-indicator:var(--color-lime-950)]',
green:
'[--radio-checked-indicator:var(--color-white)] [--radio-checked-bg:var(--color-green-600)] [--radio-checked-border:var(--color-green-700)]/90',
emerald:
'[--radio-checked-indicator:var(--color-white)] [--radio-checked-bg:var(--color-emerald-600)] [--radio-checked-border:var(--color-emerald-700)]/90',
teal: '[--radio-checked-indicator:var(--color-white)] [--radio-checked-bg:var(--color-teal-600)] [--radio-checked-border:var(--color-teal-700)]/90',
cyan: '[--radio-checked-bg:var(--color-cyan-300)] [--radio-checked-border:var(--color-cyan-400)]/80 [--radio-checked-indicator:var(--color-cyan-950)]',
sky: '[--radio-checked-indicator:var(--color-white)] [--radio-checked-bg:var(--color-sky-500)] [--radio-checked-border:var(--color-sky-600)]/80',
blue: '[--radio-checked-indicator:var(--color-white)] [--radio-checked-bg:var(--color-blue-600)] [--radio-checked-border:var(--color-blue-700)]/90',
indigo:
'[--radio-checked-indicator:var(--color-white)] [--radio-checked-bg:var(--color-indigo-500)] [--radio-checked-border:var(--color-indigo-600)]/90',
violet:
'[--radio-checked-indicator:var(--color-white)] [--radio-checked-bg:var(--color-violet-500)] [--radio-checked-border:var(--color-violet-600)]/90',
purple:
'[--radio-checked-indicator:var(--color-white)] [--radio-checked-bg:var(--color-purple-500)] [--radio-checked-border:var(--color-purple-600)]/90',
fuchsia:
'[--radio-checked-indicator:var(--color-white)] [--radio-checked-bg:var(--color-fuchsia-500)] [--radio-checked-border:var(--color-fuchsia-600)]/90',
pink: '[--radio-checked-indicator:var(--color-white)] [--radio-checked-bg:var(--color-pink-500)] [--radio-checked-border:var(--color-pink-600)]/90',
rose: '[--radio-checked-indicator:var(--color-white)] [--radio-checked-bg:var(--color-rose-500)] [--radio-checked-border:var(--color-rose-600)]/90',
}
type Color = keyof typeof colors
export function Radio({
color = 'dark/zinc',
className,
...props
}: { color?: Color; className?: string } & Omit<Headless.RadioProps, 'as' | 'className' | 'children'>) {
return (
<Headless.Radio
data-slot="control"
{...props}
className={clsx(className, 'group inline-flex focus:outline-hidden')}
>
<span className={clsx([base, colors[color]])}>
<span
className={clsx(
'size-full rounded-full border-[4.5px] border-transparent bg-(--radio-indicator) bg-clip-padding',
// Forced colors mode
'forced-colors:border-[Canvas] forced-colors:group-data-checked:border-[Highlight]'
)}
/>
</span>
</Headless.Radio>
)
}

View File

@@ -0,0 +1,68 @@
import * as Headless from '@headlessui/react'
import clsx from 'clsx'
import React, { forwardRef } from 'react'
export const Select = forwardRef(function Select(
{ className, multiple, ...props }: { className?: string } & Omit<Headless.SelectProps, 'as' | 'className'>,
ref: React.ForwardedRef<HTMLSelectElement>
) {
return (
<span
data-slot="control"
className={clsx([
className,
// Basic layout
'group relative block w-full',
// Background color + shadow applied to inset pseudo element, so shadow blends with border in light mode
'before:absolute before:inset-px before:rounded-[calc(var(--radius-lg)-1px)] before:bg-white before:shadow-sm',
// Background color is moved to control and shadow is removed in dark mode so hide `before` pseudo
'dark:before:hidden',
// Focus ring
'after:pointer-events-none after:absolute after:inset-0 after:rounded-lg after:ring-transparent after:ring-inset has-data-focus:after:ring-2 has-data-focus:after:ring-blue-500',
// Disabled state
'has-data-disabled:opacity-50 has-data-disabled:before:bg-zinc-950/5 has-data-disabled:before:shadow-none',
])}
>
<Headless.Select
ref={ref}
multiple={multiple}
{...props}
className={clsx([
// Basic layout
'relative block w-full appearance-none rounded-lg py-[calc(--spacing(2.5)-1px)] sm:py-[calc(--spacing(1.5)-1px)]',
// Horizontal padding
multiple
? 'px-[calc(--spacing(3.5)-1px)] sm:px-[calc(--spacing(3)-1px)]'
: 'pr-[calc(--spacing(10)-1px)] pl-[calc(--spacing(3.5)-1px)] sm:pr-[calc(--spacing(9)-1px)] sm:pl-[calc(--spacing(3)-1px)]',
// Options (multi-select)
'[&_optgroup]:font-semibold',
// Typography
'text-base/6 text-zinc-950 placeholder:text-zinc-500 sm:text-sm/6 dark:text-white dark:*:text-white',
// Border
'border border-zinc-950/10 data-hover:border-zinc-950/20 dark:border-white/10 dark:data-hover:border-white/20',
// Background color
'bg-transparent dark:bg-white/5 dark:*:bg-zinc-800',
// Hide default focus styles
'focus:outline-hidden',
// Invalid state
'data-invalid:border-red-500 data-invalid:data-hover:border-red-500 dark:data-invalid:border-red-600 dark:data-invalid:data-hover:border-red-600',
// Disabled state
'data-disabled:border-zinc-950/20 data-disabled:opacity-100 dark:data-disabled:border-white/15 dark:data-disabled:bg-white/2.5 dark:data-hover:data-disabled:border-white/15',
])}
/>
{!multiple && (
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<svg
className="size-5 stroke-zinc-500 group-has-data-disabled:stroke-zinc-600 sm:size-4 dark:stroke-zinc-400 forced-colors:stroke-[CanvasText]"
viewBox="0 0 16 16"
aria-hidden="true"
fill="none"
>
<path d="M5.75 10.75L8 13L10.25 10.75" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" />
<path d="M10.25 5.25L8 3L5.75 5.25" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" />
</svg>
</span>
)}
</span>
)
})

View File

@@ -0,0 +1,82 @@
'use client'
import * as Headless from '@headlessui/react'
import React, { useState } from 'react'
import { NavbarItem } from './navbar'
function OpenMenuIcon() {
return (
<svg data-slot="icon" viewBox="0 0 20 20" aria-hidden="true">
<path d="M2 6.75C2 6.33579 2.33579 6 2.75 6H17.25C17.6642 6 18 6.33579 18 6.75C18 7.16421 17.6642 7.5 17.25 7.5H2.75C2.33579 7.5 2 7.16421 2 6.75ZM2 13.25C2 12.8358 2.33579 12.5 2.75 12.5H17.25C17.6642 12.5 18 12.8358 18 13.25C18 13.6642 17.6642 14 17.25 14H2.75C2.33579 14 2 13.6642 2 13.25Z" />
</svg>
)
}
function CloseMenuIcon() {
return (
<svg data-slot="icon" viewBox="0 0 20 20" aria-hidden="true">
<path d="M6.28 5.22a.75.75 0 0 0-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 1 0 1.06 1.06L10 11.06l3.72 3.72a.75.75 0 1 0 1.06-1.06L11.06 10l3.72-3.72a.75.75 0 0 0-1.06-1.06L10 8.94 6.28 5.22Z" />
</svg>
)
}
function MobileSidebar({ open, close, children }: React.PropsWithChildren<{ open: boolean; close: () => void }>) {
return (
<Headless.Dialog open={open} onClose={close} className="lg:hidden">
<Headless.DialogBackdrop
transition
className="fixed inset-0 bg-black/30 transition data-closed:opacity-0 data-enter:duration-300 data-enter:ease-out data-leave:duration-200 data-leave:ease-in"
/>
<Headless.DialogPanel
transition
className="fixed inset-y-0 w-full max-w-80 p-2 transition duration-300 ease-in-out data-closed:-translate-x-full"
>
<div className="flex h-full flex-col rounded-lg bg-white shadow-xs ring-1 ring-zinc-950/5 dark:bg-zinc-900 dark:ring-white/10">
<div className="-mb-3 px-4 pt-3">
<Headless.CloseButton as={NavbarItem} aria-label="Close navigation">
<CloseMenuIcon />
</Headless.CloseButton>
</div>
{children}
</div>
</Headless.DialogPanel>
</Headless.Dialog>
)
}
export function SidebarLayout({
navbar,
sidebar,
children,
}: React.PropsWithChildren<{ navbar: React.ReactNode; sidebar: React.ReactNode }>) {
let [showSidebar, setShowSidebar] = useState(false)
return (
<div className="relative isolate flex min-h-svh w-full bg-white max-lg:flex-col lg:bg-zinc-100 dark:bg-zinc-900 dark:lg:bg-zinc-950">
{/* Sidebar on desktop */}
<div className="fixed inset-y-0 left-0 w-64 max-lg:hidden">{sidebar}</div>
{/* Sidebar on mobile */}
<MobileSidebar open={showSidebar} close={() => setShowSidebar(false)}>
{sidebar}
</MobileSidebar>
{/* Navbar on mobile */}
<header className="flex items-center px-4 lg:hidden">
<div className="py-2.5">
<NavbarItem onClick={() => setShowSidebar(true)} aria-label="Open navigation">
<OpenMenuIcon />
</NavbarItem>
</div>
<div className="min-w-0 flex-1">{navbar}</div>
</header>
{/* Content */}
<main className="flex flex-1 flex-col pb-2 lg:min-w-0 lg:pt-2 lg:pr-2 lg:pl-64">
<div className="grow p-6 lg:rounded-lg lg:bg-white lg:p-10 lg:shadow-xs lg:ring-1 lg:ring-zinc-950/5 dark:lg:bg-zinc-900 dark:lg:ring-white/10">
<div className="mx-auto max-w-6xl">{children}</div>
</div>
</main>
</div>
)
}

View File

@@ -0,0 +1,142 @@
'use client'
import * as Headless from '@headlessui/react'
import clsx from 'clsx'
import { LayoutGroup, motion } from 'motion/react'
import React, { forwardRef, useId } from 'react'
import { TouchTarget } from './button'
import { Link } from './link'
export function Sidebar({ className, ...props }: React.ComponentPropsWithoutRef<'nav'>) {
return <nav {...props} className={clsx(className, 'flex h-full min-h-0 flex-col')} />
}
export function SidebarHeader({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) {
return (
<div
{...props}
className={clsx(
className,
'flex flex-col border-b border-zinc-950/5 p-4 dark:border-white/5 [&>[data-slot=section]+[data-slot=section]]:mt-2.5'
)}
/>
)
}
export function SidebarBody({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) {
return (
<div
{...props}
className={clsx(
className,
'flex flex-1 flex-col overflow-y-auto p-4 [&>[data-slot=section]+[data-slot=section]]:mt-8'
)}
/>
)
}
export function SidebarFooter({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) {
return (
<div
{...props}
className={clsx(
className,
'flex flex-col border-t border-zinc-950/5 p-4 dark:border-white/5 [&>[data-slot=section]+[data-slot=section]]:mt-2.5'
)}
/>
)
}
export function SidebarSection({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) {
let id = useId()
return (
<LayoutGroup id={id}>
<div {...props} data-slot="section" className={clsx(className, 'flex flex-col gap-0.5')} />
</LayoutGroup>
)
}
export function SidebarDivider({ className, ...props }: React.ComponentPropsWithoutRef<'hr'>) {
return <hr {...props} className={clsx(className, 'my-4 border-t border-zinc-950/5 lg:-mx-4 dark:border-white/5')} />
}
export function SidebarSpacer({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) {
return <div aria-hidden="true" {...props} className={clsx(className, 'mt-8 flex-1')} />
}
export function SidebarHeading({ className, ...props }: React.ComponentPropsWithoutRef<'h3'>) {
return (
<h3 {...props} className={clsx(className, 'mb-1 px-2 text-xs/6 font-medium text-zinc-500 dark:text-zinc-400')} />
)
}
export const SidebarItem = forwardRef(function SidebarItem(
{
current,
className,
children,
...props
}: { current?: boolean; className?: string; children: React.ReactNode } & (
| ({ href?: never } & Omit<Headless.ButtonProps, 'as' | 'className'>)
| ({ href: string } & Omit<Headless.ButtonProps<typeof Link>, 'as' | 'className'>)
),
ref: React.ForwardedRef<HTMLAnchorElement | HTMLButtonElement>
) {
let classes = clsx(
// Base
'flex w-full items-center gap-3 rounded-lg px-2 py-2.5 text-left text-base/6 font-medium text-zinc-950 sm:py-2 sm:text-sm/5',
// Leading icon/icon-only
'*:data-[slot=icon]:size-6 *:data-[slot=icon]:shrink-0 *:data-[slot=icon]:fill-zinc-500 sm:*:data-[slot=icon]:size-5',
// Trailing icon (down chevron or similar)
'*:last:data-[slot=icon]:ml-auto *:last:data-[slot=icon]:size-5 sm:*:last:data-[slot=icon]:size-4',
// Avatar
'*:data-[slot=avatar]:-m-0.5 *:data-[slot=avatar]:size-7 sm:*:data-[slot=avatar]:size-6',
// Hover
'data-hover:bg-zinc-950/5 data-hover:*:data-[slot=icon]:fill-zinc-950',
// Active
'data-active:bg-zinc-950/5 data-active:*:data-[slot=icon]:fill-zinc-950',
// Current
'data-current:*:data-[slot=icon]:fill-zinc-950',
// Dark mode
'dark:text-white dark:*:data-[slot=icon]:fill-zinc-400',
'dark:data-hover:bg-white/5 dark:data-hover:*:data-[slot=icon]:fill-white',
'dark:data-active:bg-white/5 dark:data-active:*:data-[slot=icon]:fill-white',
'dark:data-current:*:data-[slot=icon]:fill-white'
)
return (
<span className={clsx(className, 'relative')}>
{current && (
<motion.span
layoutId="current-indicator"
className="absolute inset-y-2 -left-4 w-0.5 rounded-full bg-zinc-950 dark:bg-white"
/>
)}
{typeof props.href === 'string' ? (
<Headless.CloseButton
as={Link}
{...props}
className={classes}
data-current={current ? 'true' : undefined}
ref={ref}
>
<TouchTarget>{children}</TouchTarget>
</Headless.CloseButton>
) : (
<Headless.Button
{...props}
className={clsx('cursor-default', classes)}
data-current={current ? 'true' : undefined}
ref={ref}
>
<TouchTarget>{children}</TouchTarget>
</Headless.Button>
)}
</span>
)
})
export function SidebarLabel({ className, ...props }: React.ComponentPropsWithoutRef<'span'>) {
return <span {...props} className={clsx(className, 'truncate')} />
}

View File

@@ -0,0 +1,195 @@
import * as Headless from '@headlessui/react'
import clsx from 'clsx'
import type React from 'react'
export function SwitchGroup({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) {
return (
<div
data-slot="control"
{...props}
className={clsx(
className,
// Basic groups
'space-y-3 **:data-[slot=label]:font-normal',
// With descriptions
'has-data-[slot=description]:space-y-6 has-data-[slot=description]:**:data-[slot=label]:font-medium'
)}
/>
)
}
export function SwitchField({
className,
...props
}: { className?: string } & Omit<Headless.FieldProps, 'as' | 'className'>) {
return (
<Headless.Field
data-slot="field"
{...props}
className={clsx(
className,
// Base layout
'grid grid-cols-[1fr_auto] gap-x-8 gap-y-1 sm:grid-cols-[1fr_auto]',
// Control layout
'*:data-[slot=control]:col-start-2 *:data-[slot=control]:self-start sm:*:data-[slot=control]:mt-0.5',
// Label layout
'*:data-[slot=label]:col-start-1 *:data-[slot=label]:row-start-1',
// Description layout
'*:data-[slot=description]:col-start-1 *:data-[slot=description]:row-start-2',
// With description
'has-data-[slot=description]:**:data-[slot=label]:font-medium'
)}
/>
)
}
const colors = {
'dark/zinc': [
'[--switch-bg-ring:var(--color-zinc-950)]/90 [--switch-bg:var(--color-zinc-900)] dark:[--switch-bg-ring:transparent] dark:[--switch-bg:var(--color-white)]/25',
'[--switch-ring:var(--color-zinc-950)]/90 [--switch-shadow:var(--color-black)]/10 [--switch:white] dark:[--switch-ring:var(--color-zinc-700)]/90',
],
'dark/white': [
'[--switch-bg-ring:var(--color-zinc-950)]/90 [--switch-bg:var(--color-zinc-900)] dark:[--switch-bg-ring:transparent] dark:[--switch-bg:var(--color-white)]',
'[--switch-ring:var(--color-zinc-950)]/90 [--switch-shadow:var(--color-black)]/10 [--switch:white] dark:[--switch-ring:transparent] dark:[--switch:var(--color-zinc-900)]',
],
dark: [
'[--switch-bg-ring:var(--color-zinc-950)]/90 [--switch-bg:var(--color-zinc-900)] dark:[--switch-bg-ring:var(--color-white)]/15',
'[--switch-ring:var(--color-zinc-950)]/90 [--switch-shadow:var(--color-black)]/10 [--switch:white]',
],
zinc: [
'[--switch-bg-ring:var(--color-zinc-700)]/90 [--switch-bg:var(--color-zinc-600)] dark:[--switch-bg-ring:transparent]',
'[--switch-shadow:var(--color-black)]/10 [--switch:white] [--switch-ring:var(--color-zinc-700)]/90',
],
white: [
'[--switch-bg-ring:var(--color-black)]/15 [--switch-bg:white] dark:[--switch-bg-ring:transparent]',
'[--switch-shadow:var(--color-black)]/10 [--switch-ring:transparent] [--switch:var(--color-zinc-950)]',
],
red: [
'[--switch-bg-ring:var(--color-red-700)]/90 [--switch-bg:var(--color-red-600)] dark:[--switch-bg-ring:transparent]',
'[--switch:white] [--switch-ring:var(--color-red-700)]/90 [--switch-shadow:var(--color-red-900)]/20',
],
orange: [
'[--switch-bg-ring:var(--color-orange-600)]/90 [--switch-bg:var(--color-orange-500)] dark:[--switch-bg-ring:transparent]',
'[--switch:white] [--switch-ring:var(--color-orange-600)]/90 [--switch-shadow:var(--color-orange-900)]/20',
],
amber: [
'[--switch-bg-ring:var(--color-amber-500)]/80 [--switch-bg:var(--color-amber-400)] dark:[--switch-bg-ring:transparent]',
'[--switch-ring:transparent] [--switch-shadow:transparent] [--switch:var(--color-amber-950)]',
],
yellow: [
'[--switch-bg-ring:var(--color-yellow-400)]/80 [--switch-bg:var(--color-yellow-300)] dark:[--switch-bg-ring:transparent]',
'[--switch-ring:transparent] [--switch-shadow:transparent] [--switch:var(--color-yellow-950)]',
],
lime: [
'[--switch-bg-ring:var(--color-lime-400)]/80 [--switch-bg:var(--color-lime-300)] dark:[--switch-bg-ring:transparent]',
'[--switch-ring:transparent] [--switch-shadow:transparent] [--switch:var(--color-lime-950)]',
],
green: [
'[--switch-bg-ring:var(--color-green-700)]/90 [--switch-bg:var(--color-green-600)] dark:[--switch-bg-ring:transparent]',
'[--switch:white] [--switch-ring:var(--color-green-700)]/90 [--switch-shadow:var(--color-green-900)]/20',
],
emerald: [
'[--switch-bg-ring:var(--color-emerald-600)]/90 [--switch-bg:var(--color-emerald-500)] dark:[--switch-bg-ring:transparent]',
'[--switch:white] [--switch-ring:var(--color-emerald-600)]/90 [--switch-shadow:var(--color-emerald-900)]/20',
],
teal: [
'[--switch-bg-ring:var(--color-teal-700)]/90 [--switch-bg:var(--color-teal-600)] dark:[--switch-bg-ring:transparent]',
'[--switch:white] [--switch-ring:var(--color-teal-700)]/90 [--switch-shadow:var(--color-teal-900)]/20',
],
cyan: [
'[--switch-bg-ring:var(--color-cyan-400)]/80 [--switch-bg:var(--color-cyan-300)] dark:[--switch-bg-ring:transparent]',
'[--switch-ring:transparent] [--switch-shadow:transparent] [--switch:var(--color-cyan-950)]',
],
sky: [
'[--switch-bg-ring:var(--color-sky-600)]/80 [--switch-bg:var(--color-sky-500)] dark:[--switch-bg-ring:transparent]',
'[--switch:white] [--switch-ring:var(--color-sky-600)]/80 [--switch-shadow:var(--color-sky-900)]/20',
],
blue: [
'[--switch-bg-ring:var(--color-blue-700)]/90 [--switch-bg:var(--color-blue-600)] dark:[--switch-bg-ring:transparent]',
'[--switch:white] [--switch-ring:var(--color-blue-700)]/90 [--switch-shadow:var(--color-blue-900)]/20',
],
indigo: [
'[--switch-bg-ring:var(--color-indigo-600)]/90 [--switch-bg:var(--color-indigo-500)] dark:[--switch-bg-ring:transparent]',
'[--switch:white] [--switch-ring:var(--color-indigo-600)]/90 [--switch-shadow:var(--color-indigo-900)]/20',
],
violet: [
'[--switch-bg-ring:var(--color-violet-600)]/90 [--switch-bg:var(--color-violet-500)] dark:[--switch-bg-ring:transparent]',
'[--switch:white] [--switch-ring:var(--color-violet-600)]/90 [--switch-shadow:var(--color-violet-900)]/20',
],
purple: [
'[--switch-bg-ring:var(--color-purple-600)]/90 [--switch-bg:var(--color-purple-500)] dark:[--switch-bg-ring:transparent]',
'[--switch:white] [--switch-ring:var(--color-purple-600)]/90 [--switch-shadow:var(--color-purple-900)]/20',
],
fuchsia: [
'[--switch-bg-ring:var(--color-fuchsia-600)]/90 [--switch-bg:var(--color-fuchsia-500)] dark:[--switch-bg-ring:transparent]',
'[--switch:white] [--switch-ring:var(--color-fuchsia-600)]/90 [--switch-shadow:var(--color-fuchsia-900)]/20',
],
pink: [
'[--switch-bg-ring:var(--color-pink-600)]/90 [--switch-bg:var(--color-pink-500)] dark:[--switch-bg-ring:transparent]',
'[--switch:white] [--switch-ring:var(--color-pink-600)]/90 [--switch-shadow:var(--color-pink-900)]/20',
],
rose: [
'[--switch-bg-ring:var(--color-rose-600)]/90 [--switch-bg:var(--color-rose-500)] dark:[--switch-bg-ring:transparent]',
'[--switch:white] [--switch-ring:var(--color-rose-600)]/90 [--switch-shadow:var(--color-rose-900)]/20',
],
}
type Color = keyof typeof colors
export function Switch({
color = 'dark/zinc',
className,
...props
}: {
color?: Color
className?: string
} & Omit<Headless.SwitchProps, 'as' | 'className' | 'children'>) {
return (
<Headless.Switch
data-slot="control"
{...props}
className={clsx(
className,
// Base styles
'group relative isolate inline-flex h-6 w-10 cursor-default rounded-full p-[3px] sm:h-5 sm:w-8',
// Transitions
'transition duration-0 ease-in-out data-changing:duration-200',
// Outline and background color in forced-colors mode so switch is still visible
'forced-colors:outline forced-colors:[--switch-bg:Highlight] dark:forced-colors:[--switch-bg:Highlight]',
// Unchecked
'bg-zinc-200 ring-1 ring-black/5 ring-inset dark:bg-white/5 dark:ring-white/15',
// Checked
'data-checked:bg-(--switch-bg) data-checked:ring-(--switch-bg-ring) dark:data-checked:bg-(--switch-bg) dark:data-checked:ring-(--switch-bg-ring)',
// Focus
'focus:not-data-focus:outline-hidden data-focus:outline-2 data-focus:outline-offset-2 data-focus:outline-blue-500',
// Hover
'data-hover:ring-black/15 data-hover:data-checked:ring-(--switch-bg-ring)',
'dark:data-hover:ring-white/25 dark:data-hover:data-checked:ring-(--switch-bg-ring)',
// Disabled
'data-disabled:bg-zinc-200 data-disabled:opacity-50 data-disabled:data-checked:bg-zinc-200 data-disabled:data-checked:ring-black/5',
'dark:data-disabled:bg-white/15 dark:data-disabled:data-checked:bg-white/15 dark:data-disabled:data-checked:ring-white/15',
// Color specific styles
colors[color]
)}
>
<span
aria-hidden="true"
className={clsx(
// Basic layout
'pointer-events-none relative inline-block size-4.5 rounded-full sm:size-3.5',
// Transition
'translate-x-0 transition duration-200 ease-in-out',
// Invisible border so the switch is still visible in forced-colors mode
'border border-transparent',
// Unchecked
'bg-white shadow-sm ring-1 ring-black/5',
// Checked
'group-data-checked:bg-(--switch) group-data-checked:shadow-(--switch-shadow) group-data-checked:ring-(--switch-ring)',
'group-data-checked:translate-x-4 sm:group-data-checked:translate-x-3',
// Disabled
'group-data-checked:group-data-disabled:bg-white group-data-checked:group-data-disabled:shadow-sm group-data-checked:group-data-disabled:ring-black/5'
)}
/>
</Headless.Switch>
)
}

View File

@@ -0,0 +1,124 @@
'use client'
import clsx from 'clsx'
import type React from 'react'
import { createContext, useContext, useState } from 'react'
import { Link } from './link'
const TableContext = createContext<{ bleed: boolean; dense: boolean; grid: boolean; striped: boolean }>({
bleed: false,
dense: false,
grid: false,
striped: false,
})
export function Table({
bleed = false,
dense = false,
grid = false,
striped = false,
className,
children,
...props
}: { bleed?: boolean; dense?: boolean; grid?: boolean; striped?: boolean } & React.ComponentPropsWithoutRef<'div'>) {
return (
<TableContext.Provider value={{ bleed, dense, grid, striped } as React.ContextType<typeof TableContext>}>
<div className="flow-root">
<div {...props} className={clsx(className, '-mx-(--gutter) overflow-x-auto whitespace-nowrap')}>
<div className={clsx('inline-block min-w-full align-middle', !bleed && 'sm:px-(--gutter)')}>
<table className="min-w-full text-left text-sm/6 text-zinc-950 dark:text-white">{children}</table>
</div>
</div>
</div>
</TableContext.Provider>
)
}
export function TableHead({ className, ...props }: React.ComponentPropsWithoutRef<'thead'>) {
return <thead {...props} className={clsx(className, 'text-zinc-500 dark:text-zinc-400')} />
}
export function TableBody(props: React.ComponentPropsWithoutRef<'tbody'>) {
return <tbody {...props} />
}
const TableRowContext = createContext<{ href?: string; target?: string; title?: string }>({
href: undefined,
target: undefined,
title: undefined,
})
export function TableRow({
href,
target,
title,
className,
...props
}: { href?: string; target?: string; title?: string } & React.ComponentPropsWithoutRef<'tr'>) {
let { striped } = useContext(TableContext)
return (
<TableRowContext.Provider value={{ href, target, title } as React.ContextType<typeof TableRowContext>}>
<tr
{...props}
className={clsx(
className,
href &&
'has-[[data-row-link][data-focus]]:outline-2 has-[[data-row-link][data-focus]]:-outline-offset-2 has-[[data-row-link][data-focus]]:outline-blue-500 dark:focus-within:bg-white/2.5',
striped && 'even:bg-zinc-950/2.5 dark:even:bg-white/2.5',
href && striped && 'hover:bg-zinc-950/5 dark:hover:bg-white/5',
href && !striped && 'hover:bg-zinc-950/2.5 dark:hover:bg-white/2.5'
)}
/>
</TableRowContext.Provider>
)
}
export function TableHeader({ className, ...props }: React.ComponentPropsWithoutRef<'th'>) {
let { bleed, grid } = useContext(TableContext)
return (
<th
{...props}
className={clsx(
className,
'border-b border-b-zinc-950/10 px-4 py-2 font-medium first:pl-(--gutter,--spacing(2)) last:pr-(--gutter,--spacing(2)) dark:border-b-white/10',
grid && 'border-l border-l-zinc-950/5 first:border-l-0 dark:border-l-white/5',
!bleed && 'sm:first:pl-1 sm:last:pr-1'
)}
/>
)
}
export function TableCell({ className, children, ...props }: React.ComponentPropsWithoutRef<'td'>) {
let { bleed, dense, grid, striped } = useContext(TableContext)
let { href, target, title } = useContext(TableRowContext)
let [cellRef, setCellRef] = useState<HTMLElement | null>(null)
return (
<td
ref={href ? setCellRef : undefined}
{...props}
className={clsx(
className,
'relative px-4 first:pl-(--gutter,--spacing(2)) last:pr-(--gutter,--spacing(2))',
!striped && 'border-b border-zinc-950/5 dark:border-white/5',
grid && 'border-l border-l-zinc-950/5 first:border-l-0 dark:border-l-white/5',
dense ? 'py-2.5' : 'py-4',
!bleed && 'sm:first:pl-1 sm:last:pr-1'
)}
>
{href && (
<Link
data-row-link
href={href}
target={target}
aria-label={title}
tabIndex={cellRef?.previousElementSibling === null ? 0 : -1}
className="absolute inset-0 focus:outline-hidden"
/>
)}
{children}
</td>
)
}

View File

@@ -0,0 +1,41 @@
import clsx from 'clsx'
import React from 'react'
import { Link } from './link'
export function Text({ className, ...props }: React.ComponentPropsWithoutRef<'p'>) {
return (
<p
data-slot="text"
{...props}
className={clsx(className, 'text-base/6 text-zinc-500 sm:text-sm/6 dark:text-zinc-400')}
/>
)
}
export function TextLink({ className, ...props }: React.ComponentPropsWithoutRef<typeof Link>) {
return (
<Link
{...props}
className={clsx(
className,
'text-zinc-950 underline decoration-zinc-950/50 data-hover:decoration-zinc-950 dark:text-white dark:decoration-white/50 dark:data-hover:decoration-white'
)}
/>
)
}
export function Strong({ className, ...props }: React.ComponentPropsWithoutRef<'strong'>) {
return <strong {...props} className={clsx(className, 'font-medium text-zinc-950 dark:text-white')} />
}
export function Code({ className, ...props }: React.ComponentPropsWithoutRef<'code'>) {
return (
<code
{...props}
className={clsx(
className,
'rounded-sm border border-zinc-950/10 bg-zinc-950/2.5 px-0.5 text-sm font-medium text-zinc-950 sm:text-[0.8125rem] dark:border-white/20 dark:bg-white/5 dark:text-white'
)}
/>
)
}

View File

@@ -0,0 +1,54 @@
import * as Headless from '@headlessui/react'
import clsx from 'clsx'
import React, { forwardRef } from 'react'
export const Textarea = forwardRef(function Textarea(
{
className,
resizable = true,
...props
}: { className?: string; resizable?: boolean } & Omit<Headless.TextareaProps, 'as' | 'className'>,
ref: React.ForwardedRef<HTMLTextAreaElement>
) {
return (
<span
data-slot="control"
className={clsx([
className,
// Basic layout
'relative block w-full',
// Background color + shadow applied to inset pseudo element, so shadow blends with border in light mode
'before:absolute before:inset-px before:rounded-[calc(var(--radius-lg)-1px)] before:bg-white before:shadow-sm',
// Background color is moved to control and shadow is removed in dark mode so hide `before` pseudo
'dark:before:hidden',
// Focus ring
'after:pointer-events-none after:absolute after:inset-0 after:rounded-lg after:ring-transparent after:ring-inset sm:focus-within:after:ring-2 sm:focus-within:after:ring-blue-500',
// Disabled state
'has-data-disabled:opacity-50 has-data-disabled:before:bg-zinc-950/5 has-data-disabled:before:shadow-none',
])}
>
<Headless.Textarea
ref={ref}
{...props}
className={clsx([
// Basic layout
'relative block h-full w-full appearance-none rounded-lg px-[calc(--spacing(3.5)-1px)] py-[calc(--spacing(2.5)-1px)] sm:px-[calc(--spacing(3)-1px)] sm:py-[calc(--spacing(1.5)-1px)]',
// Typography
'text-base/6 text-zinc-950 placeholder:text-zinc-500 sm:text-sm/6 dark:text-white',
// Border
'border border-zinc-950/10 data-hover:border-zinc-950/20 dark:border-white/10 dark:data-hover:border-white/20',
// Background color
'bg-transparent dark:bg-white/5',
// Hide default focus styles
'focus:outline-hidden',
// Invalid state
'data-invalid:border-red-500 data-invalid:data-hover:border-red-500 dark:data-invalid:border-red-600 dark:data-invalid:data-hover:border-red-600',
// Disabled state
'disabled:border-zinc-950/20 dark:disabled:border-white/15 dark:disabled:bg-white/2.5 dark:data-hover:disabled:border-white/15',
// Resizable
resizable ? 'resize-y' : 'resize-none',
])}
/>
</span>
)
})