mirror of
https://github.com/escalante29/healthy-fit.git
synced 2026-03-21 15:08:46 +01:00
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:
14
frontend/src/components/Layout/AppLayout.tsx
Normal file
14
frontend/src/components/Layout/AppLayout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
13
frontend/src/components/Layout/AppNavbar.tsx
Normal file
13
frontend/src/components/Layout/AppNavbar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
97
frontend/src/components/Layout/AppSidebar.tsx
Normal file
97
frontend/src/components/Layout/AppSidebar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
17
frontend/src/components/Layout/MainLayout.tsx
Normal file
17
frontend/src/components/Layout/MainLayout.tsx
Normal 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;
|
||||
124
frontend/src/components/Layout/Sidebar.tsx
Normal file
124
frontend/src/components/Layout/Sidebar.tsx
Normal 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;
|
||||
87
frontend/src/components/catalyst/avatar.tsx
Normal file
87
frontend/src/components/catalyst/avatar.tsx
Normal 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>
|
||||
)
|
||||
})
|
||||
82
frontend/src/components/catalyst/badge.tsx
Normal file
82
frontend/src/components/catalyst/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 } & (
|
||||
| ({ 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>
|
||||
)
|
||||
})
|
||||
204
frontend/src/components/catalyst/button.tsx
Normal file
204
frontend/src/components/catalyst/button.tsx
Normal file
@@ -0,0 +1,204 @@
|
||||
import * as Headless from '@headlessui/react'
|
||||
import clsx from 'clsx'
|
||||
import React, { forwardRef } from 'react'
|
||||
import { Link } from './link'
|
||||
|
||||
const styles = {
|
||||
base: [
|
||||
// Base
|
||||
'relative isolate inline-flex items-baseline justify-center gap-x-2 rounded-lg border text-base/6 font-semibold',
|
||||
// Sizing
|
||||
'px-[calc(--spacing(3.5)-1px)] py-[calc(--spacing(2.5)-1px)] sm:px-[calc(--spacing(3)-1px)] sm:py-[calc(--spacing(1.5)-1px)] sm:text-sm/6',
|
||||
// Focus
|
||||
'focus: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}
|
||||
</>
|
||||
)
|
||||
}
|
||||
157
frontend/src/components/catalyst/checkbox.tsx
Normal file
157
frontend/src/components/catalyst/checkbox.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
import * as Headless from '@headlessui/react'
|
||||
import clsx from 'clsx'
|
||||
import type React from 'react'
|
||||
|
||||
export function CheckboxGroup({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="control"
|
||||
{...props}
|
||||
className={clsx(
|
||||
className,
|
||||
// Basic groups
|
||||
'space-y-3',
|
||||
// With descriptions
|
||||
'has-data-[slot=description]:space-y-6 has-data-[slot=description]:**:data-[slot=label]:font-medium'
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function CheckboxField({
|
||||
className,
|
||||
...props
|
||||
}: { className?: string } & Omit<Headless.FieldProps, 'as' | 'className'>) {
|
||||
return (
|
||||
<Headless.Field
|
||||
data-slot="field"
|
||||
{...props}
|
||||
className={clsx(
|
||||
className,
|
||||
// Base layout
|
||||
'grid grid-cols-[1.125rem_1fr] gap-x-4 gap-y-1 sm:grid-cols-[1rem_1fr]',
|
||||
// Control layout
|
||||
'*:data-[slot=control]:col-start-1 *:data-[slot=control]:row-start-1 *:data-[slot=control]:mt-0.75 sm:*:data-[slot=control]:mt-1',
|
||||
// Label layout
|
||||
'*:data-[slot=label]:col-start-2 *:data-[slot=label]:row-start-1',
|
||||
// Description layout
|
||||
'*:data-[slot=description]:col-start-2 *:data-[slot=description]:row-start-2',
|
||||
// With description
|
||||
'has-data-[slot=description]:**:data-[slot=label]:font-medium'
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const base = [
|
||||
// Basic layout
|
||||
'relative isolate flex size-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>
|
||||
)
|
||||
}
|
||||
86
frontend/src/components/catalyst/dialog.tsx
Normal file
86
frontend/src/components/catalyst/dialog.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import * as Headless from '@headlessui/react'
|
||||
import clsx from 'clsx'
|
||||
import type React from 'react'
|
||||
import { Text } from './text'
|
||||
|
||||
const sizes = {
|
||||
xs: 'sm:max-w-xs',
|
||||
sm: 'sm:max-w-sm',
|
||||
md: 'sm:max-w-md',
|
||||
lg: 'sm:max-w-lg',
|
||||
xl: 'sm:max-w-xl',
|
||||
'2xl': 'sm:max-w-2xl',
|
||||
'3xl': 'sm:max-w-3xl',
|
||||
'4xl': 'sm:max-w-4xl',
|
||||
'5xl': 'sm:max-w-5xl',
|
||||
}
|
||||
|
||||
export function Dialog({
|
||||
size = 'lg',
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: { size?: keyof typeof sizes; className?: string; children: React.ReactNode } & Omit<
|
||||
Headless.DialogProps,
|
||||
'as' | 'className'
|
||||
>) {
|
||||
return (
|
||||
<Headless.Dialog {...props}>
|
||||
<Headless.DialogBackdrop
|
||||
transition
|
||||
className="fixed inset-0 flex w-screen justify-center overflow-y-auto bg-zinc-950/25 px-2 py-2 transition duration-100 focus:outline-0 data-closed:opacity-0 data-enter:ease-out data-leave:ease-in sm:px-6 sm:py-8 lg:px-8 lg:py-16 dark:bg-zinc-950/50"
|
||||
/>
|
||||
|
||||
<div className="fixed inset-0 w-screen overflow-y-auto pt-6 sm:pt-0">
|
||||
<div className="grid min-h-full grid-rows-[1fr_auto] justify-items-center sm:grid-rows-[1fr_auto_3fr] sm:p-4">
|
||||
<Headless.DialogPanel
|
||||
transition
|
||||
className={clsx(
|
||||
className,
|
||||
sizes[size],
|
||||
'row-start-2 w-full min-w-0 rounded-t-3xl bg-white p-(--gutter) shadow-lg ring-1 ring-zinc-950/10 [--gutter:--spacing(8)] sm:mb-auto sm:rounded-2xl dark:bg-zinc-900 dark:ring-white/10 forced-colors:outline',
|
||||
'transition duration-100 will-change-transform data-closed:translate-y-12 data-closed:opacity-0 data-enter:ease-out data-leave:ease-in sm:data-closed:translate-y-0 sm:data-closed:data-enter:scale-95'
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</Headless.DialogPanel>
|
||||
</div>
|
||||
</div>
|
||||
</Headless.Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export function DialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: { className?: string } & Omit<Headless.DialogTitleProps, 'as' | 'className'>) {
|
||||
return (
|
||||
<Headless.DialogTitle
|
||||
{...props}
|
||||
className={clsx(className, 'text-lg/6 font-semibold text-balance text-zinc-950 sm:text-base/6 dark:text-white')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: { className?: string } & Omit<Headless.DescriptionProps<typeof Text>, 'as' | 'className'>) {
|
||||
return <Headless.Description as={Text} {...props} className={clsx(className, 'mt-2 text-pretty')} />
|
||||
}
|
||||
|
||||
export function DialogBody({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) {
|
||||
return <div {...props} className={clsx(className, 'mt-6')} />
|
||||
}
|
||||
|
||||
export function DialogActions({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) {
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
className={clsx(
|
||||
className,
|
||||
'mt-8 flex flex-col-reverse items-center justify-end gap-3 *:w-full sm:flex-row sm:*:w-auto'
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
91
frontend/src/components/catalyst/fieldset.tsx
Normal file
91
frontend/src/components/catalyst/fieldset.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import * as Headless from '@headlessui/react'
|
||||
import clsx from 'clsx'
|
||||
import type React from 'react'
|
||||
|
||||
export function Fieldset({
|
||||
className,
|
||||
...props
|
||||
}: { className?: string } & Omit<Headless.FieldsetProps, 'as' | 'className'>) {
|
||||
return (
|
||||
<Headless.Fieldset
|
||||
{...props}
|
||||
className={clsx(className, '*:data-[slot=text]:mt-1 [&>*+[data-slot=control]]:mt-6')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function Legend({
|
||||
className,
|
||||
...props
|
||||
}: { className?: string } & Omit<Headless.LegendProps, 'as' | 'className'>) {
|
||||
return (
|
||||
<Headless.Legend
|
||||
data-slot="legend"
|
||||
{...props}
|
||||
className={clsx(
|
||||
className,
|
||||
'text-base/6 font-semibold text-zinc-950 data-disabled:opacity-50 sm:text-sm/6 dark:text-white'
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function FieldGroup({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) {
|
||||
return <div data-slot="control" {...props} className={clsx(className, 'space-y-8')} />
|
||||
}
|
||||
|
||||
export function Field({ className, ...props }: { className?: string } & Omit<Headless.FieldProps, 'as' | 'className'>) {
|
||||
return (
|
||||
<Headless.Field
|
||||
{...props}
|
||||
className={clsx(
|
||||
className,
|
||||
'[&>[data-slot=label]+[data-slot=control]]:mt-3',
|
||||
'[&>[data-slot=label]+[data-slot=description]]:mt-1',
|
||||
'[&>[data-slot=description]+[data-slot=control]]:mt-3',
|
||||
'[&>[data-slot=control]+[data-slot=description]]:mt-3',
|
||||
'[&>[data-slot=control]+[data-slot=error]]:mt-3',
|
||||
'*:data-[slot=label]:font-medium'
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function Label({ className, ...props }: { className?: string } & Omit<Headless.LabelProps, 'as' | 'className'>) {
|
||||
return (
|
||||
<Headless.Label
|
||||
data-slot="label"
|
||||
{...props}
|
||||
className={clsx(
|
||||
className,
|
||||
'text-base/6 text-zinc-950 select-none data-disabled:opacity-50 sm:text-sm/6 dark:text-white'
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function Description({
|
||||
className,
|
||||
...props
|
||||
}: { className?: string } & Omit<Headless.DescriptionProps, 'as' | 'className'>) {
|
||||
return (
|
||||
<Headless.Description
|
||||
data-slot="description"
|
||||
{...props}
|
||||
className={clsx(className, 'text-base/6 text-zinc-500 data-disabled:opacity-50 sm:text-sm/6 dark:text-zinc-400')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function ErrorMessage({
|
||||
className,
|
||||
...props
|
||||
}: { className?: string } & Omit<Headless.DescriptionProps, 'as' | 'className'>) {
|
||||
return (
|
||||
<Headless.Description
|
||||
data-slot="error"
|
||||
{...props}
|
||||
className={clsx(className, 'text-base/6 text-red-600 data-disabled:opacity-50 sm:text-sm/6 dark:text-red-500')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
28
frontend/src/components/catalyst/heading.tsx
Normal file
28
frontend/src/components/catalyst/heading.tsx
Normal 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')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
92
frontend/src/components/catalyst/input.tsx
Normal file
92
frontend/src/components/catalyst/input.tsx
Normal 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>
|
||||
)
|
||||
})
|
||||
18
frontend/src/components/catalyst/link.tsx
Normal file
18
frontend/src/components/catalyst/link.tsx
Normal 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>
|
||||
)
|
||||
})
|
||||
96
frontend/src/components/catalyst/navbar.tsx
Normal file
96
frontend/src/components/catalyst/navbar.tsx
Normal 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')} />
|
||||
}
|
||||
142
frontend/src/components/catalyst/radio.tsx
Normal file
142
frontend/src/components/catalyst/radio.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
import * as Headless from '@headlessui/react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
export function RadioGroup({
|
||||
className,
|
||||
...props
|
||||
}: { className?: string } & Omit<Headless.RadioGroupProps, 'as' | 'className'>) {
|
||||
return (
|
||||
<Headless.RadioGroup
|
||||
data-slot="control"
|
||||
{...props}
|
||||
className={clsx(
|
||||
className,
|
||||
// Basic groups
|
||||
'space-y-3 **:data-[slot=label]:font-normal',
|
||||
// With descriptions
|
||||
'has-data-[slot=description]:space-y-6 has-data-[slot=description]:**:data-[slot=label]:font-medium'
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function RadioField({
|
||||
className,
|
||||
...props
|
||||
}: { className?: string } & Omit<Headless.FieldProps, 'as' | 'className'>) {
|
||||
return (
|
||||
<Headless.Field
|
||||
data-slot="field"
|
||||
{...props}
|
||||
className={clsx(
|
||||
className,
|
||||
// Base layout
|
||||
'grid grid-cols-[1.125rem_1fr] gap-x-4 gap-y-1 sm:grid-cols-[1rem_1fr]',
|
||||
// Control layout
|
||||
'*:data-[slot=control]:col-start-1 *:data-[slot=control]:row-start-1 *:data-[slot=control]:mt-0.75 sm:*:data-[slot=control]:mt-1',
|
||||
// Label layout
|
||||
'*:data-[slot=label]:col-start-2 *:data-[slot=label]:row-start-1',
|
||||
// Description layout
|
||||
'*:data-[slot=description]:col-start-2 *:data-[slot=description]:row-start-2',
|
||||
// With description
|
||||
'has-data-[slot=description]:**:data-[slot=label]:font-medium'
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const base = [
|
||||
// Basic layout
|
||||
'relative isolate flex size-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>
|
||||
)
|
||||
}
|
||||
68
frontend/src/components/catalyst/select.tsx
Normal file
68
frontend/src/components/catalyst/select.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import * as Headless from '@headlessui/react'
|
||||
import clsx from 'clsx'
|
||||
import React, { forwardRef } from 'react'
|
||||
|
||||
export const Select = forwardRef(function Select(
|
||||
{ className, multiple, ...props }: { className?: string } & Omit<Headless.SelectProps, 'as' | 'className'>,
|
||||
ref: React.ForwardedRef<HTMLSelectElement>
|
||||
) {
|
||||
return (
|
||||
<span
|
||||
data-slot="control"
|
||||
className={clsx([
|
||||
className,
|
||||
// Basic layout
|
||||
'group relative block w-full',
|
||||
// Background color + shadow applied to inset pseudo element, so shadow blends with border in light mode
|
||||
'before:absolute before:inset-px before:rounded-[calc(var(--radius-lg)-1px)] before:bg-white before:shadow-sm',
|
||||
// Background color is moved to control and shadow is removed in dark mode so hide `before` pseudo
|
||||
'dark:before:hidden',
|
||||
// Focus ring
|
||||
'after:pointer-events-none after:absolute after:inset-0 after:rounded-lg after:ring-transparent after:ring-inset has-data-focus:after:ring-2 has-data-focus:after:ring-blue-500',
|
||||
// Disabled state
|
||||
'has-data-disabled:opacity-50 has-data-disabled:before:bg-zinc-950/5 has-data-disabled:before:shadow-none',
|
||||
])}
|
||||
>
|
||||
<Headless.Select
|
||||
ref={ref}
|
||||
multiple={multiple}
|
||||
{...props}
|
||||
className={clsx([
|
||||
// Basic layout
|
||||
'relative block w-full appearance-none rounded-lg py-[calc(--spacing(2.5)-1px)] sm:py-[calc(--spacing(1.5)-1px)]',
|
||||
// Horizontal padding
|
||||
multiple
|
||||
? 'px-[calc(--spacing(3.5)-1px)] sm:px-[calc(--spacing(3)-1px)]'
|
||||
: 'pr-[calc(--spacing(10)-1px)] pl-[calc(--spacing(3.5)-1px)] sm:pr-[calc(--spacing(9)-1px)] sm:pl-[calc(--spacing(3)-1px)]',
|
||||
// Options (multi-select)
|
||||
'[&_optgroup]:font-semibold',
|
||||
// Typography
|
||||
'text-base/6 text-zinc-950 placeholder:text-zinc-500 sm:text-sm/6 dark:text-white dark:*:text-white',
|
||||
// Border
|
||||
'border border-zinc-950/10 data-hover:border-zinc-950/20 dark:border-white/10 dark:data-hover:border-white/20',
|
||||
// Background color
|
||||
'bg-transparent dark:bg-white/5 dark:*:bg-zinc-800',
|
||||
// Hide default focus styles
|
||||
'focus:outline-hidden',
|
||||
// Invalid state
|
||||
'data-invalid:border-red-500 data-invalid:data-hover:border-red-500 dark:data-invalid:border-red-600 dark:data-invalid:data-hover:border-red-600',
|
||||
// Disabled state
|
||||
'data-disabled:border-zinc-950/20 data-disabled:opacity-100 dark:data-disabled:border-white/15 dark:data-disabled:bg-white/2.5 dark:data-hover:data-disabled:border-white/15',
|
||||
])}
|
||||
/>
|
||||
{!multiple && (
|
||||
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
|
||||
<svg
|
||||
className="size-5 stroke-zinc-500 group-has-data-disabled:stroke-zinc-600 sm:size-4 dark:stroke-zinc-400 forced-colors:stroke-[CanvasText]"
|
||||
viewBox="0 0 16 16"
|
||||
aria-hidden="true"
|
||||
fill="none"
|
||||
>
|
||||
<path d="M5.75 10.75L8 13L10.25 10.75" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M10.25 5.25L8 3L5.75 5.25" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
})
|
||||
82
frontend/src/components/catalyst/sidebar-layout.tsx
Normal file
82
frontend/src/components/catalyst/sidebar-layout.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
'use client'
|
||||
|
||||
import * as Headless from '@headlessui/react'
|
||||
import React, { useState } from 'react'
|
||||
import { NavbarItem } from './navbar'
|
||||
|
||||
function OpenMenuIcon() {
|
||||
return (
|
||||
<svg data-slot="icon" viewBox="0 0 20 20" aria-hidden="true">
|
||||
<path d="M2 6.75C2 6.33579 2.33579 6 2.75 6H17.25C17.6642 6 18 6.33579 18 6.75C18 7.16421 17.6642 7.5 17.25 7.5H2.75C2.33579 7.5 2 7.16421 2 6.75ZM2 13.25C2 12.8358 2.33579 12.5 2.75 12.5H17.25C17.6642 12.5 18 12.8358 18 13.25C18 13.6642 17.6642 14 17.25 14H2.75C2.33579 14 2 13.6642 2 13.25Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function CloseMenuIcon() {
|
||||
return (
|
||||
<svg data-slot="icon" viewBox="0 0 20 20" aria-hidden="true">
|
||||
<path d="M6.28 5.22a.75.75 0 0 0-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 1 0 1.06 1.06L10 11.06l3.72 3.72a.75.75 0 1 0 1.06-1.06L11.06 10l3.72-3.72a.75.75 0 0 0-1.06-1.06L10 8.94 6.28 5.22Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function MobileSidebar({ open, close, children }: React.PropsWithChildren<{ open: boolean; close: () => void }>) {
|
||||
return (
|
||||
<Headless.Dialog open={open} onClose={close} className="lg:hidden">
|
||||
<Headless.DialogBackdrop
|
||||
transition
|
||||
className="fixed inset-0 bg-black/30 transition data-closed:opacity-0 data-enter:duration-300 data-enter:ease-out data-leave:duration-200 data-leave:ease-in"
|
||||
/>
|
||||
<Headless.DialogPanel
|
||||
transition
|
||||
className="fixed inset-y-0 w-full max-w-80 p-2 transition duration-300 ease-in-out data-closed:-translate-x-full"
|
||||
>
|
||||
<div className="flex h-full flex-col rounded-lg bg-white shadow-xs ring-1 ring-zinc-950/5 dark:bg-zinc-900 dark:ring-white/10">
|
||||
<div className="-mb-3 px-4 pt-3">
|
||||
<Headless.CloseButton as={NavbarItem} aria-label="Close navigation">
|
||||
<CloseMenuIcon />
|
||||
</Headless.CloseButton>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</Headless.DialogPanel>
|
||||
</Headless.Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export function SidebarLayout({
|
||||
navbar,
|
||||
sidebar,
|
||||
children,
|
||||
}: React.PropsWithChildren<{ navbar: React.ReactNode; sidebar: React.ReactNode }>) {
|
||||
let [showSidebar, setShowSidebar] = useState(false)
|
||||
|
||||
return (
|
||||
<div className="relative isolate flex min-h-svh w-full bg-white max-lg:flex-col lg:bg-zinc-100 dark:bg-zinc-900 dark:lg:bg-zinc-950">
|
||||
{/* Sidebar on desktop */}
|
||||
<div className="fixed inset-y-0 left-0 w-64 max-lg:hidden">{sidebar}</div>
|
||||
|
||||
{/* Sidebar on mobile */}
|
||||
<MobileSidebar open={showSidebar} close={() => setShowSidebar(false)}>
|
||||
{sidebar}
|
||||
</MobileSidebar>
|
||||
|
||||
{/* Navbar on mobile */}
|
||||
<header className="flex items-center px-4 lg:hidden">
|
||||
<div className="py-2.5">
|
||||
<NavbarItem onClick={() => setShowSidebar(true)} aria-label="Open navigation">
|
||||
<OpenMenuIcon />
|
||||
</NavbarItem>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">{navbar}</div>
|
||||
</header>
|
||||
|
||||
{/* Content */}
|
||||
<main className="flex flex-1 flex-col pb-2 lg:min-w-0 lg:pt-2 lg:pr-2 lg:pl-64">
|
||||
<div className="grow p-6 lg:rounded-lg lg:bg-white lg:p-10 lg:shadow-xs lg:ring-1 lg:ring-zinc-950/5 dark:lg:bg-zinc-900 dark:lg:ring-white/10">
|
||||
<div className="mx-auto max-w-6xl">{children}</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
142
frontend/src/components/catalyst/sidebar.tsx
Normal file
142
frontend/src/components/catalyst/sidebar.tsx
Normal 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')} />
|
||||
}
|
||||
195
frontend/src/components/catalyst/switch.tsx
Normal file
195
frontend/src/components/catalyst/switch.tsx
Normal file
@@ -0,0 +1,195 @@
|
||||
import * as Headless from '@headlessui/react'
|
||||
import clsx from 'clsx'
|
||||
import type React from 'react'
|
||||
|
||||
export function SwitchGroup({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="control"
|
||||
{...props}
|
||||
className={clsx(
|
||||
className,
|
||||
// Basic groups
|
||||
'space-y-3 **:data-[slot=label]:font-normal',
|
||||
// With descriptions
|
||||
'has-data-[slot=description]:space-y-6 has-data-[slot=description]:**:data-[slot=label]:font-medium'
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function SwitchField({
|
||||
className,
|
||||
...props
|
||||
}: { className?: string } & Omit<Headless.FieldProps, 'as' | 'className'>) {
|
||||
return (
|
||||
<Headless.Field
|
||||
data-slot="field"
|
||||
{...props}
|
||||
className={clsx(
|
||||
className,
|
||||
// Base layout
|
||||
'grid grid-cols-[1fr_auto] gap-x-8 gap-y-1 sm:grid-cols-[1fr_auto]',
|
||||
// Control layout
|
||||
'*:data-[slot=control]:col-start-2 *:data-[slot=control]:self-start sm:*:data-[slot=control]:mt-0.5',
|
||||
// Label layout
|
||||
'*:data-[slot=label]:col-start-1 *:data-[slot=label]:row-start-1',
|
||||
// Description layout
|
||||
'*:data-[slot=description]:col-start-1 *:data-[slot=description]:row-start-2',
|
||||
// With description
|
||||
'has-data-[slot=description]:**:data-[slot=label]:font-medium'
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const colors = {
|
||||
'dark/zinc': [
|
||||
'[--switch-bg-ring:var(--color-zinc-950)]/90 [--switch-bg:var(--color-zinc-900)] dark:[--switch-bg-ring:transparent] dark:[--switch-bg:var(--color-white)]/25',
|
||||
'[--switch-ring:var(--color-zinc-950)]/90 [--switch-shadow:var(--color-black)]/10 [--switch:white] dark:[--switch-ring:var(--color-zinc-700)]/90',
|
||||
],
|
||||
'dark/white': [
|
||||
'[--switch-bg-ring:var(--color-zinc-950)]/90 [--switch-bg:var(--color-zinc-900)] dark:[--switch-bg-ring:transparent] dark:[--switch-bg:var(--color-white)]',
|
||||
'[--switch-ring:var(--color-zinc-950)]/90 [--switch-shadow:var(--color-black)]/10 [--switch:white] dark:[--switch-ring:transparent] dark:[--switch:var(--color-zinc-900)]',
|
||||
],
|
||||
dark: [
|
||||
'[--switch-bg-ring:var(--color-zinc-950)]/90 [--switch-bg:var(--color-zinc-900)] dark:[--switch-bg-ring:var(--color-white)]/15',
|
||||
'[--switch-ring:var(--color-zinc-950)]/90 [--switch-shadow:var(--color-black)]/10 [--switch:white]',
|
||||
],
|
||||
zinc: [
|
||||
'[--switch-bg-ring:var(--color-zinc-700)]/90 [--switch-bg:var(--color-zinc-600)] dark:[--switch-bg-ring:transparent]',
|
||||
'[--switch-shadow:var(--color-black)]/10 [--switch:white] [--switch-ring:var(--color-zinc-700)]/90',
|
||||
],
|
||||
white: [
|
||||
'[--switch-bg-ring:var(--color-black)]/15 [--switch-bg:white] dark:[--switch-bg-ring:transparent]',
|
||||
'[--switch-shadow:var(--color-black)]/10 [--switch-ring:transparent] [--switch:var(--color-zinc-950)]',
|
||||
],
|
||||
red: [
|
||||
'[--switch-bg-ring:var(--color-red-700)]/90 [--switch-bg:var(--color-red-600)] dark:[--switch-bg-ring:transparent]',
|
||||
'[--switch:white] [--switch-ring:var(--color-red-700)]/90 [--switch-shadow:var(--color-red-900)]/20',
|
||||
],
|
||||
orange: [
|
||||
'[--switch-bg-ring:var(--color-orange-600)]/90 [--switch-bg:var(--color-orange-500)] dark:[--switch-bg-ring:transparent]',
|
||||
'[--switch:white] [--switch-ring:var(--color-orange-600)]/90 [--switch-shadow:var(--color-orange-900)]/20',
|
||||
],
|
||||
amber: [
|
||||
'[--switch-bg-ring:var(--color-amber-500)]/80 [--switch-bg:var(--color-amber-400)] dark:[--switch-bg-ring:transparent]',
|
||||
'[--switch-ring:transparent] [--switch-shadow:transparent] [--switch:var(--color-amber-950)]',
|
||||
],
|
||||
yellow: [
|
||||
'[--switch-bg-ring:var(--color-yellow-400)]/80 [--switch-bg:var(--color-yellow-300)] dark:[--switch-bg-ring:transparent]',
|
||||
'[--switch-ring:transparent] [--switch-shadow:transparent] [--switch:var(--color-yellow-950)]',
|
||||
],
|
||||
lime: [
|
||||
'[--switch-bg-ring:var(--color-lime-400)]/80 [--switch-bg:var(--color-lime-300)] dark:[--switch-bg-ring:transparent]',
|
||||
'[--switch-ring:transparent] [--switch-shadow:transparent] [--switch:var(--color-lime-950)]',
|
||||
],
|
||||
green: [
|
||||
'[--switch-bg-ring:var(--color-green-700)]/90 [--switch-bg:var(--color-green-600)] dark:[--switch-bg-ring:transparent]',
|
||||
'[--switch:white] [--switch-ring:var(--color-green-700)]/90 [--switch-shadow:var(--color-green-900)]/20',
|
||||
],
|
||||
emerald: [
|
||||
'[--switch-bg-ring:var(--color-emerald-600)]/90 [--switch-bg:var(--color-emerald-500)] dark:[--switch-bg-ring:transparent]',
|
||||
'[--switch:white] [--switch-ring:var(--color-emerald-600)]/90 [--switch-shadow:var(--color-emerald-900)]/20',
|
||||
],
|
||||
teal: [
|
||||
'[--switch-bg-ring:var(--color-teal-700)]/90 [--switch-bg:var(--color-teal-600)] dark:[--switch-bg-ring:transparent]',
|
||||
'[--switch:white] [--switch-ring:var(--color-teal-700)]/90 [--switch-shadow:var(--color-teal-900)]/20',
|
||||
],
|
||||
cyan: [
|
||||
'[--switch-bg-ring:var(--color-cyan-400)]/80 [--switch-bg:var(--color-cyan-300)] dark:[--switch-bg-ring:transparent]',
|
||||
'[--switch-ring:transparent] [--switch-shadow:transparent] [--switch:var(--color-cyan-950)]',
|
||||
],
|
||||
sky: [
|
||||
'[--switch-bg-ring:var(--color-sky-600)]/80 [--switch-bg:var(--color-sky-500)] dark:[--switch-bg-ring:transparent]',
|
||||
'[--switch:white] [--switch-ring:var(--color-sky-600)]/80 [--switch-shadow:var(--color-sky-900)]/20',
|
||||
],
|
||||
blue: [
|
||||
'[--switch-bg-ring:var(--color-blue-700)]/90 [--switch-bg:var(--color-blue-600)] dark:[--switch-bg-ring:transparent]',
|
||||
'[--switch:white] [--switch-ring:var(--color-blue-700)]/90 [--switch-shadow:var(--color-blue-900)]/20',
|
||||
],
|
||||
indigo: [
|
||||
'[--switch-bg-ring:var(--color-indigo-600)]/90 [--switch-bg:var(--color-indigo-500)] dark:[--switch-bg-ring:transparent]',
|
||||
'[--switch:white] [--switch-ring:var(--color-indigo-600)]/90 [--switch-shadow:var(--color-indigo-900)]/20',
|
||||
],
|
||||
violet: [
|
||||
'[--switch-bg-ring:var(--color-violet-600)]/90 [--switch-bg:var(--color-violet-500)] dark:[--switch-bg-ring:transparent]',
|
||||
'[--switch:white] [--switch-ring:var(--color-violet-600)]/90 [--switch-shadow:var(--color-violet-900)]/20',
|
||||
],
|
||||
purple: [
|
||||
'[--switch-bg-ring:var(--color-purple-600)]/90 [--switch-bg:var(--color-purple-500)] dark:[--switch-bg-ring:transparent]',
|
||||
'[--switch:white] [--switch-ring:var(--color-purple-600)]/90 [--switch-shadow:var(--color-purple-900)]/20',
|
||||
],
|
||||
fuchsia: [
|
||||
'[--switch-bg-ring:var(--color-fuchsia-600)]/90 [--switch-bg:var(--color-fuchsia-500)] dark:[--switch-bg-ring:transparent]',
|
||||
'[--switch:white] [--switch-ring:var(--color-fuchsia-600)]/90 [--switch-shadow:var(--color-fuchsia-900)]/20',
|
||||
],
|
||||
pink: [
|
||||
'[--switch-bg-ring:var(--color-pink-600)]/90 [--switch-bg:var(--color-pink-500)] dark:[--switch-bg-ring:transparent]',
|
||||
'[--switch:white] [--switch-ring:var(--color-pink-600)]/90 [--switch-shadow:var(--color-pink-900)]/20',
|
||||
],
|
||||
rose: [
|
||||
'[--switch-bg-ring:var(--color-rose-600)]/90 [--switch-bg:var(--color-rose-500)] dark:[--switch-bg-ring:transparent]',
|
||||
'[--switch:white] [--switch-ring:var(--color-rose-600)]/90 [--switch-shadow:var(--color-rose-900)]/20',
|
||||
],
|
||||
}
|
||||
|
||||
type Color = keyof typeof colors
|
||||
|
||||
export function Switch({
|
||||
color = 'dark/zinc',
|
||||
className,
|
||||
...props
|
||||
}: {
|
||||
color?: Color
|
||||
className?: string
|
||||
} & Omit<Headless.SwitchProps, 'as' | 'className' | 'children'>) {
|
||||
return (
|
||||
<Headless.Switch
|
||||
data-slot="control"
|
||||
{...props}
|
||||
className={clsx(
|
||||
className,
|
||||
// Base styles
|
||||
'group relative isolate inline-flex h-6 w-10 cursor-default rounded-full p-[3px] sm:h-5 sm:w-8',
|
||||
// Transitions
|
||||
'transition duration-0 ease-in-out data-changing:duration-200',
|
||||
// Outline and background color in forced-colors mode so switch is still visible
|
||||
'forced-colors:outline forced-colors:[--switch-bg:Highlight] dark:forced-colors:[--switch-bg:Highlight]',
|
||||
// Unchecked
|
||||
'bg-zinc-200 ring-1 ring-black/5 ring-inset dark:bg-white/5 dark:ring-white/15',
|
||||
// Checked
|
||||
'data-checked:bg-(--switch-bg) data-checked:ring-(--switch-bg-ring) dark:data-checked:bg-(--switch-bg) dark:data-checked:ring-(--switch-bg-ring)',
|
||||
// Focus
|
||||
'focus: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>
|
||||
)
|
||||
}
|
||||
124
frontend/src/components/catalyst/table.tsx
Normal file
124
frontend/src/components/catalyst/table.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
'use client'
|
||||
|
||||
import clsx from 'clsx'
|
||||
import type React from 'react'
|
||||
import { createContext, useContext, useState } from 'react'
|
||||
import { Link } from './link'
|
||||
|
||||
const TableContext = createContext<{ bleed: boolean; dense: boolean; grid: boolean; striped: boolean }>({
|
||||
bleed: false,
|
||||
dense: false,
|
||||
grid: false,
|
||||
striped: false,
|
||||
})
|
||||
|
||||
export function Table({
|
||||
bleed = false,
|
||||
dense = false,
|
||||
grid = false,
|
||||
striped = false,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: { bleed?: boolean; dense?: boolean; grid?: boolean; striped?: boolean } & React.ComponentPropsWithoutRef<'div'>) {
|
||||
return (
|
||||
<TableContext.Provider value={{ bleed, dense, grid, striped } as React.ContextType<typeof TableContext>}>
|
||||
<div className="flow-root">
|
||||
<div {...props} className={clsx(className, '-mx-(--gutter) overflow-x-auto whitespace-nowrap')}>
|
||||
<div className={clsx('inline-block min-w-full align-middle', !bleed && 'sm:px-(--gutter)')}>
|
||||
<table className="min-w-full text-left text-sm/6 text-zinc-950 dark:text-white">{children}</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TableContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function TableHead({ className, ...props }: React.ComponentPropsWithoutRef<'thead'>) {
|
||||
return <thead {...props} className={clsx(className, 'text-zinc-500 dark:text-zinc-400')} />
|
||||
}
|
||||
|
||||
export function TableBody(props: React.ComponentPropsWithoutRef<'tbody'>) {
|
||||
return <tbody {...props} />
|
||||
}
|
||||
|
||||
const TableRowContext = createContext<{ href?: string; target?: string; title?: string }>({
|
||||
href: undefined,
|
||||
target: undefined,
|
||||
title: undefined,
|
||||
})
|
||||
|
||||
export function TableRow({
|
||||
href,
|
||||
target,
|
||||
title,
|
||||
className,
|
||||
...props
|
||||
}: { href?: string; target?: string; title?: string } & React.ComponentPropsWithoutRef<'tr'>) {
|
||||
let { striped } = useContext(TableContext)
|
||||
|
||||
return (
|
||||
<TableRowContext.Provider value={{ href, target, title } as React.ContextType<typeof TableRowContext>}>
|
||||
<tr
|
||||
{...props}
|
||||
className={clsx(
|
||||
className,
|
||||
href &&
|
||||
'has-[[data-row-link][data-focus]]:outline-2 has-[[data-row-link][data-focus]]:-outline-offset-2 has-[[data-row-link][data-focus]]:outline-blue-500 dark:focus-within:bg-white/2.5',
|
||||
striped && 'even:bg-zinc-950/2.5 dark:even:bg-white/2.5',
|
||||
href && striped && 'hover:bg-zinc-950/5 dark:hover:bg-white/5',
|
||||
href && !striped && 'hover:bg-zinc-950/2.5 dark:hover:bg-white/2.5'
|
||||
)}
|
||||
/>
|
||||
</TableRowContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function TableHeader({ className, ...props }: React.ComponentPropsWithoutRef<'th'>) {
|
||||
let { bleed, grid } = useContext(TableContext)
|
||||
|
||||
return (
|
||||
<th
|
||||
{...props}
|
||||
className={clsx(
|
||||
className,
|
||||
'border-b border-b-zinc-950/10 px-4 py-2 font-medium first:pl-(--gutter,--spacing(2)) last:pr-(--gutter,--spacing(2)) dark:border-b-white/10',
|
||||
grid && 'border-l border-l-zinc-950/5 first:border-l-0 dark:border-l-white/5',
|
||||
!bleed && 'sm:first:pl-1 sm:last:pr-1'
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function TableCell({ className, children, ...props }: React.ComponentPropsWithoutRef<'td'>) {
|
||||
let { bleed, dense, grid, striped } = useContext(TableContext)
|
||||
let { href, target, title } = useContext(TableRowContext)
|
||||
let [cellRef, setCellRef] = useState<HTMLElement | null>(null)
|
||||
|
||||
return (
|
||||
<td
|
||||
ref={href ? setCellRef : undefined}
|
||||
{...props}
|
||||
className={clsx(
|
||||
className,
|
||||
'relative px-4 first:pl-(--gutter,--spacing(2)) last:pr-(--gutter,--spacing(2))',
|
||||
!striped && 'border-b border-zinc-950/5 dark:border-white/5',
|
||||
grid && 'border-l border-l-zinc-950/5 first:border-l-0 dark:border-l-white/5',
|
||||
dense ? 'py-2.5' : 'py-4',
|
||||
!bleed && 'sm:first:pl-1 sm:last:pr-1'
|
||||
)}
|
||||
>
|
||||
{href && (
|
||||
<Link
|
||||
data-row-link
|
||||
href={href}
|
||||
target={target}
|
||||
aria-label={title}
|
||||
tabIndex={cellRef?.previousElementSibling === null ? 0 : -1}
|
||||
className="absolute inset-0 focus:outline-hidden"
|
||||
/>
|
||||
)}
|
||||
{children}
|
||||
</td>
|
||||
)
|
||||
}
|
||||
41
frontend/src/components/catalyst/text.tsx
Normal file
41
frontend/src/components/catalyst/text.tsx
Normal 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'
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
54
frontend/src/components/catalyst/textarea.tsx
Normal file
54
frontend/src/components/catalyst/textarea.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import * as Headless from '@headlessui/react'
|
||||
import clsx from 'clsx'
|
||||
import React, { forwardRef } from 'react'
|
||||
|
||||
export const Textarea = forwardRef(function Textarea(
|
||||
{
|
||||
className,
|
||||
resizable = true,
|
||||
...props
|
||||
}: { className?: string; resizable?: boolean } & Omit<Headless.TextareaProps, 'as' | 'className'>,
|
||||
ref: React.ForwardedRef<HTMLTextAreaElement>
|
||||
) {
|
||||
return (
|
||||
<span
|
||||
data-slot="control"
|
||||
className={clsx([
|
||||
className,
|
||||
// Basic layout
|
||||
'relative block w-full',
|
||||
// Background color + shadow applied to inset pseudo element, so shadow blends with border in light mode
|
||||
'before:absolute before:inset-px before:rounded-[calc(var(--radius-lg)-1px)] before:bg-white before:shadow-sm',
|
||||
// Background color is moved to control and shadow is removed in dark mode so hide `before` pseudo
|
||||
'dark:before:hidden',
|
||||
// Focus ring
|
||||
'after:pointer-events-none after:absolute after:inset-0 after:rounded-lg after:ring-transparent after:ring-inset sm:focus-within:after:ring-2 sm:focus-within:after:ring-blue-500',
|
||||
// Disabled state
|
||||
'has-data-disabled:opacity-50 has-data-disabled:before:bg-zinc-950/5 has-data-disabled:before:shadow-none',
|
||||
])}
|
||||
>
|
||||
<Headless.Textarea
|
||||
ref={ref}
|
||||
{...props}
|
||||
className={clsx([
|
||||
// Basic layout
|
||||
'relative block h-full w-full appearance-none rounded-lg px-[calc(--spacing(3.5)-1px)] py-[calc(--spacing(2.5)-1px)] sm:px-[calc(--spacing(3)-1px)] sm:py-[calc(--spacing(1.5)-1px)]',
|
||||
// Typography
|
||||
'text-base/6 text-zinc-950 placeholder:text-zinc-500 sm:text-sm/6 dark:text-white',
|
||||
// Border
|
||||
'border border-zinc-950/10 data-hover:border-zinc-950/20 dark:border-white/10 dark:data-hover:border-white/20',
|
||||
// Background color
|
||||
'bg-transparent dark:bg-white/5',
|
||||
// Hide default focus styles
|
||||
'focus:outline-hidden',
|
||||
// Invalid state
|
||||
'data-invalid:border-red-500 data-invalid:data-hover:border-red-500 dark:data-invalid:border-red-600 dark:data-invalid:data-hover:border-red-600',
|
||||
// Disabled state
|
||||
'disabled:border-zinc-950/20 dark:disabled:border-white/15 dark:disabled:bg-white/2.5 dark:data-hover:disabled:border-white/15',
|
||||
// Resizable
|
||||
resizable ? 'resize-y' : 'resize-none',
|
||||
])}
|
||||
/>
|
||||
</span>
|
||||
)
|
||||
})
|
||||
Reference in New Issue
Block a user