Auth page

This commit is contained in:
Arthur Belleville 2025-03-17 18:06:33 +01:00
parent 50ba9b340b
commit 5e4c33a168
No known key found for this signature in database
69 changed files with 9132 additions and 1296 deletions

View file

@ -2,8 +2,9 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<link rel="icon" type="image/jpg+xml" href="/public/icon.jpg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" href="/src/ui-components/theme/index.css" />
<title>XTablo</title>
</head>
<body>

View file

@ -9,23 +9,36 @@
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.0.0",
"react-aria-components": "^1.6.0",
"react-dom": "^19.0.0",
"tailwindcss-react-aria-components": "^1.2.0"
},
"devDependencies": {
"@eslint/js": "^9.19.0",
"@types/react": "^19.0.8",
"@types/react-dom": "^19.0.3",
"@floating-ui/react": "^0.27.4",
"@react-aria/toast": "^3.0.0",
"@react-stately/toast": "^3.0.0",
"@tailwindcss/container-queries": "^0.1.1",
"@types/react": "19.0.10",
"@types/react-dom": "19.0.4",
"@typescript-eslint/eslint-plugin": "^7.0.2",
"@typescript-eslint/parser": "^7.0.2",
"@vitejs/plugin-react": "^4.3.4",
"eslint": "^9.19.0",
"eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-react-refresh": "^0.4.18",
"globals": "^15.14.0",
"typescript": "~5.7.2",
"typescript-eslint": "^8.22.0",
"vite": "^6.1.0"
"chromatic": "^11.5.0",
"eslint": "^9.20.1",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.5",
"eslint-plugin-storybook": "^0.11.3",
"lucide-react": "^0.460.0",
"postcss": "^8.4.35",
"prettier-plugin-tailwindcss": "^0.6.11",
"react": "19.0.0",
"react-aria": "^3.38.1",
"react-aria-components": "^1.7.0",
"react-dom": "19.0.0",
"rollup-plugin-visualizer": "^5.14.0",
"tailwind-merge": "^3.0.2",
"tailwindcss": "^4.0.14",
"tailwindcss-animate": "^1.0.7",
"typescript": "^5.7.0",
"vite": "^6.2.2"
},
"dependencies": {
"@tailwindcss/vite": "^4.0.14"
}
}

File diff suppressed because it is too large Load diff

BIN
ui/public/icon.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

View file

@ -1,42 +0,0 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}

View file

@ -1,58 +1,63 @@
import { useState } from "react";
import reactLogo from "./assets/react.svg";
import viteLogo from "/vite.svg";
import "./App.css";
import {
Button,
Label,
ListBox,
ListBoxItem,
Popover,
Select,
SelectValue,
} from "react-aria-components";
import { FieldError, Label } from "./ui-components/field";
import { Heading, SubHeading } from "./ui-components/heading";
import { Form } from "./ui-components/form";
import { Text, Strong, TextLink } from "./ui-components/text";
import { Button } from "./ui-components/button";
import { Checkbox } from "./ui-components/checkbox";
import { TextField, Input } from "./ui-components/field";
import { ChevronRightIcon } from "./ui-components/icons";
import { PasswordInput } from "./ui-components/password-input";
function App() {
const [count, setCount] = useState(0);
return (
<>
<div>
<a href="https://vite.dev" target="_blank">
<img src={viteLogo} className="logo" alt="Vite logo" />
</a>
<a href="https://react.dev" target="_blank">
<img src={reactLogo} className="logo react" alt="React logo" />
</a>
<div className="flex min-h-svh flex-col justify-center gap-6 px-2 py-4">
<img className="mx-auto h-10 w-auto" src="./public/icon.jpg"></img>
<div className="p-2 sm:p-4 sm:mx-auto sm:w-full sm:max-w-sm">
<div>
<Heading level={2} displayLevel={1} className="text-center">
Sign in to your account
</Heading>
<SubHeading className="text-center">
Welcome back! Please sign into continue
</SubHeading>
</div>
<Form className="mt-6 flex flex-col space-y-4">
<TextField isRequired>
<Label>Email address</Label>
<Input type="email" />
<FieldError></FieldError>
</TextField>
<TextField isRequired>
<div className="flex">
<Label>Password</Label>
<TextLink className="text-muted ms-auto mb-1 no-underline">
Forgot password?
</TextLink>
</div>
<PasswordInput />
<FieldError></FieldError>
</TextField>
<Checkbox>Remember me</Checkbox>
<Button type="submit">
Continue <ChevronRightIcon />
</Button>
<Button variant="outline">Sign in with Google</Button>
</Form>
</div>
<h1>XTablo</h1>
<div className="card">
<button onClick={() => setCount((count) => count + 1)}>
count is {count}
</button>
<p>
Edit <code>src/App.tsx</code> and save to test HMR
</p>
<div className="text-center">
<Text>
Do you have account?{" "}
<Strong>
<TextLink>Register</TextLink>
</Strong>
</Text>
</div>
<Select>
<Label>Favorite Animal</Label>
<Button>
<SelectValue />
<span aria-hidden="true"></span>
</Button>
<Popover>
<ListBox>
<ListBoxItem>Cat</ListBoxItem>
<ListBoxItem>Dog</ListBoxItem>
<ListBoxItem>Kangaroo</ListBoxItem>
</ListBox>
</Popover>
</Select>
<p className="read-the-docs">
Click on the Vite and React logos to learn more
</p>
</>
</div>
);
}

View file

@ -1,68 +0,0 @@
:root {
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
}

View file

@ -1,10 +1,9 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App.tsx";
createRoot(document.getElementById('root')!).render(
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>,
)
</StrictMode>
);

View file

@ -0,0 +1,172 @@
import React from 'react';
import { getInitials, getInitialsToken } from './initials';
import { twMerge } from 'tailwind-merge';
import { useImageLoadingStatus } from './hooks/use-image-loading-status';
const AvatarContext = React.createContext<{
badgeId: string;
} | null>(null);
export type AvatarProps = {
src?: string;
alt: string;
colorless?: boolean;
fallback?: 'initials' | 'icon';
} & React.JSX.IntrinsicElements['div'];
export function Avatar({
colorless = false,
className,
children,
src,
alt,
fallback = 'initials',
}: AvatarProps) {
const badgeId = React.useId();
const avatarId = React.useId();
const ariaLabelledby = [avatarId, children ? badgeId : ''].join(' ');
const status = useImageLoadingStatus(src);
return (
<AvatarContext.Provider value={{ badgeId }}>
<div
role="img"
className={twMerge([
'group ring-background @container relative isolate flex size-10 shrink-0 rounded-lg',
'[&.rounded-full>:is(svg,img)]:rounded-full [&>:is(svg,img)]:size-full [&>:is(svg,img)]:rounded-lg',
className,
])}
aria-labelledby={ariaLabelledby}
>
{status === 'error' ? (
fallback === 'initials' ? (
<FallbackInitials alt={alt} id={avatarId} colorless={colorless} />
) : (
<FallbackIcon alt={alt} id={avatarId} colorless={colorless} />
)
) : (
<img
aria-hidden
id={avatarId}
src={src}
alt={alt}
className="object-cover"
/>
)}
{children}
</div>
</AvatarContext.Provider>
);
}
type AvatarFallback = {
alt: string;
id: string;
colorless: boolean;
};
function FallbackIcon({ alt, id, colorless }: AvatarFallback) {
const token = getInitialsToken(alt, colorless);
return (
<svg
aria-hidden
id={id}
aria-label={alt}
fill="currentColor"
style={{ '--avatar-token': token } as React.CSSProperties}
className="bg-(--avatar-token) text-zinc-50"
viewBox="0 0 80 80"
>
<g>
<path d="M 8 80 a 28 24 0 0 1 64 0"></path>
<circle cx="40" cy="32" r="16"></circle>
</g>
</svg>
);
}
function FallbackInitials({ alt, id, colorless }: AvatarFallback) {
const initials = getInitials(alt);
const token = getInitialsToken(alt, colorless);
return (
<svg
aria-hidden
id={id}
aria-label={alt}
fill="currentColor"
viewBox="0 0 24 24"
style={{ '--avatar-token': token } as React.CSSProperties}
className="bg-(--avatar-token) font-medium text-zinc-50"
>
<text
x="50%"
y="50%"
alignmentBaseline="middle"
dominantBaseline="middle"
textAnchor="middle"
dy=".125em"
fontSize="65%"
>
{initials}
</text>
</svg>
);
}
type AvatarBadgeProps = {
className?: string;
badge: React.ReactNode;
};
export const AvatarBadge = ({ badge, ...props }: AvatarBadgeProps) => {
const context = React.useContext(AvatarContext);
if (!context) {
throw new Error('<AvatarContext.Provider> is required');
}
return (
<span
aria-hidden
id={context.badgeId}
className={twMerge([
'grid place-items-center',
'@[32px]:size-2/5 @[40px]:size-1/3 @[64px]:size-1/4 @[128px]:size-1/5',
'border-background bg-background absolute end-0 bottom-0 z-1 z-10 rounded-full border-2',
'translate-x-[15%] translate-y-[20%]',
'in-[.rounded-full]:translate-x-[35%] in-[.rounded-full]:translate-y-[5%] in-[.rounded-full]:rtl:translate-y-[45%]',
'@-[40px]:in-[.rounded-full]:translate-x-[15%]',
'@-[64px]:in-[.rounded-full]:-translate-x-[5%] @-[64px]:in-[.rounded-full]:-translate-y-[10%]',
'@-[128px]:in-[.rounded-full]:translate-x-[-20%]',
props.className,
])}
>
{badge}
</span>
);
};
type AvatarGroupProps = {
reverseOverlap?: boolean;
} & React.JSX.IntrinsicElements['div'];
export function AvatarGroup({
reverseOverlap = false,
className,
...props
}: AvatarGroupProps) {
return (
<div
{...props}
className={twMerge(
'flex items-center -space-x-2 rtl:space-x-reverse',
'[&>[role=img]:not([class*=ring-4])]:ring-2',
reverseOverlap &&
'flex-row-reverse justify-end [&>[role=img]:last-of-type]:-me-2',
className,
)}
/>
);
}

View file

@ -0,0 +1,48 @@
import {
Breadcrumb as RACBreadcrumb,
Breadcrumbs as RACBreadcrumbs,
BreadcrumbProps as RACBreadcrumbProps,
BreadcrumbsProps as RACBreadcrumbsProps,
LinkProps,
composeRenderProps,
} from 'react-aria-components';
import { twMerge } from 'tailwind-merge';
import { Link } from './link';
import { ChevronRightIcon } from './icons';
export function Breadcrumbs<T extends object>({
className,
...props
}: RACBreadcrumbsProps<T>) {
return (
<RACBreadcrumbs {...props} className={twMerge('flex gap-1', className)} />
);
}
type BreadcrumbProps = RACBreadcrumbProps & LinkProps;
export function Breadcrumb(props: BreadcrumbProps) {
return (
<RACBreadcrumb
{...props}
className={composeRenderProps(
props.className as RACBreadcrumbProps['className'],
(className) => {
return twMerge('flex items-center gap-1', className);
},
)}
>
<Link
{...props}
className={({ isDisabled, isHovered }) => {
return twMerge(
'underline underline-offset-2',
isDisabled && 'opacity-100',
!isHovered && 'decoration-muted',
);
}}
/>
{props.href && <ChevronRightIcon className="size-4 text-muted" />}
</RACBreadcrumb>
);
}

View file

@ -0,0 +1,367 @@
import React from 'react';
import {
Button as RACButton,
ButtonProps as RACButtonProps,
ToggleButton as RACToggleButton,
ToggleButtonProps as RACToggleButtonProps,
ToggleButtonGroup as RACToggleButtonGroup,
ToggleButtonGroupProps,
composeRenderProps,
} from 'react-aria-components';
import { twMerge } from 'tailwind-merge';
import { AsChildProps, Slot } from './slot';
import { SpinnerIcon } from './icons';
import { NonFousableTooltipTarget, TooltipTrigger } from './tooltip';
type Color = 'accent' | 'success' | 'destructive';
type Size = 'sm' | 'lg';
type Variant = 'solid' | 'outline' | 'plain' | 'unstyle';
export type ButtonStyleProps = {
color?: Color;
size?: Size;
isCustomPending?: boolean;
isIconOnly?: boolean;
pendingLabel?: string;
variant?: Variant;
};
export type ButtonWithAsChildProps = AsChildProps<
RACButtonProps & {
tooltip?: React.ReactNode;
allowTooltipOnDisabled?: boolean;
}
> &
ButtonStyleProps;
export type ButtonProps = RACButtonProps &
ButtonStyleProps & {
tooltip?: React.ReactNode;
};
const buttonStyle = ({
size,
color,
isIconOnly,
variant = 'solid',
isPending,
isDisabled,
isFocusVisible,
isCustomPending,
}: ButtonStyleProps & {
isPending?: boolean;
isDisabled?: boolean;
isFocusVisible?: boolean;
}) => {
const base = [
'relative rounded-md',
isFocusVisible
? 'outline outline-2 outline-ring outline-offset-2'
: 'outline-hidden',
isDisabled && 'opacity-50',
];
if (variant === 'unstyle') {
return base;
}
const style = {
base,
variant: {
base: 'group inline-flex gap-x-2 justify-center items-center font-semibold text-base/6 sm:text-sm/6',
solid: [
'border border-transparent bg-[var(--btn-bg)]',
'[--btn-color:lch(from_var(--btn-bg)_calc((49.44_-_l)_*_infinity)_0_0)]',
'text-[var(--btn-color)]',
!isDisabled && 'hover:opacity-90',
],
outline: [
'border text-[var(--btn-color)] shadow-xs',
!isDisabled && 'hover:bg-zinc-50 dark:hover:bg-zinc-800',
],
plain: [
'text-[var(--btn-color)]',
!isDisabled && 'hover:bg-zinc-100 dark:hover:bg-zinc-800',
],
},
size: {
base: '[&_svg[data-ui=icon]:not([class*=size-])]:size-[var(--icon-size)]',
sm: [
isIconOnly
? 'size-8 sm:size-7 [--icon-size:theme(size.5)] sm:[--icon-size:theme(size.4)]'
: 'h-8 sm:h-7 [--icon-size:theme(size.3)] text-sm/6 sm:text-xs/6 px-3 sm:px-2',
],
md: [
// lg: 44px, sm:36px
'[--icon-size:theme(size.5)] sm:[--icon-size:theme(size.4)]',
isIconOnly
? 'p-[calc(--spacing(2.5)-1px)] sm:p-[calc(--spacing(1.5)-1px)] [&_svg[data-ui=icon]]:m-0.5 sm:[&_svg[data-ui=icon]]:m-1'
: 'px-[calc(--spacing(3.5)-1px)] sm:px-[calc(--spacing(3)-1px)] py-[calc(--spacing(2.5)-1px)] sm:py-[calc(--spacing(1.5)-1px)]',
],
lg: [
'[--icon-size:theme(size.5)]',
isIconOnly
? 'p-[calc(--spacing(2.5)-1px)] [&_svg[data-ui=icon]]:m-0.5'
: 'px-[calc(--spacing(3.5)-1px)] py-[calc(--spacing(2.5)-1px)]',
],
},
color: {
foreground: '[--btn-color:var(--color-foreground)]',
accent: '[--btn-color:var(--color-accent)]',
destructive: '[--btn-color:var(--color-destructive)]',
success: '[--btn-color:var(--color-success)]',
},
iconColor: {
base: '[&:not(:hover)_svg[data-ui=icon]:not([class*=text-])]:text-[var(--icon-color)]',
solid:
!isIconOnly &&
'[--icon-color:lch(from_var(--btn-color)_calc(0.85*l)_c_h)]',
outline: !isIconOnly && '[--icon-color:var(--color-muted)]',
plain: !isIconOnly && '[--icon-color:var(--color-muted)]',
},
backgroundColor: {
accent: '[--btn-bg:var(--color-accent)]',
destructive: '[--btn-bg:var(--color-destructive)]',
success: '[--btn-bg:var(--color-success)]',
},
};
return [
style.base,
style.color[color ?? 'foreground'],
style.variant.base,
style.variant[variant],
style.size.base,
style.size[size ?? 'md'],
style.iconColor.base,
style.iconColor[variant],
style.backgroundColor[color ?? 'accent'],
!isCustomPending && isPending && 'text-transparent',
];
};
export const Button = React.forwardRef<
HTMLButtonElement,
ButtonWithAsChildProps
>(function Button(props, ref) {
if (props.asChild) {
return (
<Slot className={twMerge(buttonStyle(props))}>{props.children}</Slot>
);
}
const {
asChild,
tooltip,
allowTooltipOnDisabled,
children,
isCustomPending,
pendingLabel,
size,
color,
variant = 'solid',
isIconOnly,
...buttonProps
} = props;
const button = (
<RACButton
{...buttonProps}
ref={ref}
data-variant={variant}
className={composeRenderProps(props.className, (className, renderProps) =>
twMerge([
buttonStyle({
size,
color,
isIconOnly,
variant,
isCustomPending,
...renderProps,
}),
className,
]),
)}
>
{(renderProps) => {
return (
<>
{renderProps.isPending ? (
<>
<SpinnerIcon
aria-label={pendingLabel}
className={twMerge(
'absolute',
isCustomPending ? 'sr-only' : 'flex',
)}
/>
<span
className="contents"
{...(renderProps.isPending && { 'aria-hidden': true })}
>
{typeof children === 'function'
? children(renderProps)
: children}
</span>
</>
) : typeof children === 'function' ? (
children(renderProps)
) : (
children
)}
</>
);
}}
</RACButton>
);
if (tooltip) {
if (allowTooltipOnDisabled && buttonProps.isDisabled) {
return (
<TooltipTrigger>
<NonFousableTooltipTarget>
<div className="content">{button}</div>
</NonFousableTooltipTarget>
{tooltip}
</TooltipTrigger>
);
}
return (
<TooltipTrigger>
{button}
{tooltip}
</TooltipTrigger>
);
}
return button;
});
export function ToggleButton(
props: RACToggleButtonProps &
ButtonStyleProps & {
tooltip?: React.ReactNode;
allowTooltipOnDisabled?: boolean;
},
) {
const {
variant,
tooltip,
allowTooltipOnDisabled,
size,
isIconOnly,
color,
...buttonProps
} = props;
const toggleButton = (
<RACToggleButton
{...buttonProps}
data-variant={variant}
className={composeRenderProps(
props.className,
(className, renderProps) => {
return twMerge(
buttonStyle({ variant, size, isIconOnly, color, ...renderProps }),
className,
);
},
)}
/>
);
if (tooltip) {
if (allowTooltipOnDisabled && buttonProps.isDisabled) {
return (
<TooltipTrigger>
<NonFousableTooltipTarget>
<div className="content">{toggleButton}</div>
</NonFousableTooltipTarget>
{tooltip}
</TooltipTrigger>
);
}
return (
<TooltipTrigger>
{toggleButton}
{tooltip}
</TooltipTrigger>
);
}
return toggleButton;
}
const buttonGroupStyle = ({
inline,
orientation = 'horizontal',
}: {
inline?: boolean;
orientation?: 'horizontal' | 'vertical';
}) => {
const style = {
base: [
'group inline-flex w-max items-center',
'[&>*:not(:first-child):not(:last-child)]:rounded-none',
'[&>*[data-variant=solid]:not(:first-child)]:border-s-[lch(from_var(--btn-bg)_calc(l*0.85)_c_h)]',
],
horizontal: [
'[&>*:first-child]:rounded-e-none',
'[&>*:last-child]:rounded-s-none',
'[&>*:not(:last-child)]:border-e-0',
inline && 'shadow-xs [&>*:not(:first-child)]:border-s-0 *:shadow-none',
],
vertical: [
'flex-col',
'[&>*:first-child]:rounded-b-none',
'[&>*:last-child]:rounded-t-none',
'[&>*:not(:last-child)]:border-b-0',
inline && 'shadow-xs [&>*:not(:first-child)]:border-t-0 *:shadow-none',
],
};
return [style.base, style[orientation]];
};
export function ToggleButtonGroup({
inline,
orientation = 'horizontal',
...props
}: ToggleButtonGroupProps & {
inline?: boolean;
orientation?: 'horizontal' | 'vertical';
}) {
return (
<RACToggleButtonGroup
{...props}
data-ui="button-group"
className={composeRenderProps(props.className, (className) =>
twMerge(buttonGroupStyle({ inline, orientation }), className),
)}
/>
);
}
export function ButtonGroup({
className,
inline,
orientation = 'horizontal',
...props
}: React.JSX.IntrinsicElements['div'] & {
inline?: boolean;
orientation?: 'horizontal' | 'vertical';
}) {
return (
<div
{...props}
data-ui="button-group"
className={twMerge(buttonGroupStyle({ inline, orientation }), className)}
/>
);
}

View file

@ -0,0 +1,279 @@
import React from 'react';
import {
Heading,
Calendar as RACCalendar,
CalendarGridHeader as RACCalendarGridHeader,
CalendarProps as RACCalendarProps,
CalendarCell,
CalendarGrid,
CalendarGridBody,
CalendarHeaderCell,
DateValue,
Text,
useLocale,
composeRenderProps,
CalendarStateContext,
} from 'react-aria-components';
import { Button, ButtonGroup } from './button';
import { twMerge } from 'tailwind-merge';
import { ChevronLeftIcon, ChevronRightIcon } from './icons';
import {
CalendarDate,
getLocalTimeZone,
isToday,
} from '@internationalized/date';
import { CalendarState } from '@react-stately/calendar';
import { useDateFormatter } from '@react-aria/i18n';
import { NativeSelect, NativeSelectField } from './native-select';
import { Label } from './field';
export type YearRange = number | [yearsBefore: number, yearsAfter: number];
export interface CalendarProps<T extends DateValue>
extends Omit<RACCalendarProps<T>, 'visibleDuration'> {
yearRange?: YearRange;
errorMessage?: string;
}
export function Calendar<T extends DateValue>({
errorMessage,
yearRange,
...props
}: CalendarProps<T>) {
return (
<RACCalendar
{...props}
className={composeRenderProps(props.className, (className) => {
return twMerge('px-1 py-2.5', className);
})}
>
<CalendarHeader yearRange={yearRange} />
<CalendarGrid
weekdayStyle="short"
className="w-full border-separate border-spacing-y-1 px-2"
>
<CalendarGridHeader />
<CalendarGridBody>
{(date) => {
return (
<CalendarCell
date={date}
className={composeRenderProps(
'',
(
className,
{
isHovered,
isPressed,
isDisabled,
isSelected,
isInvalid,
isUnavailable,
isFocusVisible,
},
) => {
return twMerge(
'relative flex size-10 cursor-default items-center justify-center rounded-lg text-sm outline-hidden',
isToday(date, getLocalTimeZone()) &&
'bg-zinc-100 dark:bg-zinc-800',
isHovered && 'bg-zinc-100 dark:bg-zinc-800',
isPressed && 'bg-accent/90 text-white',
isDisabled && 'opacity-50',
isSelected && [
'bg-accent text-sm text-[lch(from_var(--color-accent)_calc((49.44_-_l)_*_infinity)_0_0)]',
isHovered && 'bg-accent dark:bg-accent',
isInvalid &&
'border-destructive bg-destructive text-white',
],
isUnavailable &&
'text-destructive decoration-destructive line-through',
isFocusVisible && [
'outline-ring outline outline-2',
isSelected && 'outline-offset-1',
],
className,
);
},
)}
/>
);
}}
</CalendarGridBody>
</CalendarGrid>
{errorMessage && (
<Text slot="errorMessage" className="text-destructive text-sm">
{errorMessage}
</Text>
)}
</RACCalendar>
);
}
// https://github.com/adobe/react-spectrum/discussions/3950#discussioncomment-4851719
export function CalendarHeader({ yearRange }: { yearRange?: YearRange }) {
const { direction } = useLocale();
const state = React.use(CalendarStateContext)!;
return (
<header
className={twMerge(
'flex w-full items-center py-1 ps-4 pe-2',
yearRange ? 'ps-2' : 'ps-4',
)}
>
{yearRange ? (
<div className="flex flex-1 gap-x-2 text-center text-left text-base/6 sm:text-sm/6 rtl:text-right">
<MonthDropdown state={state} />
<YearDropdown state={state} yearRange={yearRange} />
</div>
) : (
<Heading
level={2}
className="flex flex-1 text-center text-left text-base/6 font-medium sm:text-sm/6 rtl:text-right"
aria-hidden
></Heading>
)}
<ButtonGroup>
<Button
slot="previous"
variant="plain"
size="sm"
isIconOnly
aria-label="Previous"
className="[&:not(:hover)]:text-muted/75 focus-visible:-outline-offset-2"
>
{direction === 'rtl' ? (
<ChevronRightIcon className="sm:size-5" />
) : (
<ChevronLeftIcon className="sm:size-5" />
)}
</Button>
<Button
size="sm"
slot="next"
variant="plain"
isIconOnly
aria-label="Next"
className="[&:not(:hover)]:text-muted/75 focus-visible:-outline-offset-2"
>
{direction === 'rtl' ? (
<ChevronLeftIcon className="sm:size-5" />
) : (
<ChevronRightIcon className="sm:size-5" />
)}
</Button>
</ButtonGroup>
</header>
);
}
export function CalendarGridHeader() {
return (
<RACCalendarGridHeader>
{(day) => (
<CalendarHeaderCell className="text-muted size-10 text-sm/6 font-normal">
{day}
</CalendarHeaderCell>
)}
</RACCalendarGridHeader>
);
}
function YearDropdown({
state,
yearRange,
}: {
state: CalendarState;
yearRange: YearRange;
}) {
const years: Array<{
value: CalendarDate;
formatted: string;
}> = [];
const formatter = useDateFormatter({
year: 'numeric',
timeZone: state.timeZone,
});
const [yearsBefore, yearsAfter] = Array.isArray(yearRange)
? yearRange
: [yearRange, yearRange];
if (yearsBefore <= 0 || yearsAfter <= 0) {
throw new Error(
'The yearRange prop must be a positive number or an array of two positive numbers.',
);
}
for (let i = yearsBefore * -1; i <= yearsAfter; i++) {
const date = state.focusedDate.add({ years: i });
years.push({
value: date,
formatted: formatter.format(date.toDate(state.timeZone)),
});
}
const onChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const index = Number(e.target.value);
const date = years[index].value;
state.setFocusedDate(date);
};
return (
<NativeSelectField>
<Label className="sr-only">Year</Label>
<NativeSelect onChange={onChange} value={yearsBefore}>
{years.map((year, i) => (
// use the index as the value so we can retrieve the full
// date object from the list in onChange. We cannot only
// store the year number, because in some calendars, such
// as the Japanese, the era may also change.
<option key={i} value={i}>
{year.formatted}
</option>
))}
</NativeSelect>
</NativeSelectField>
);
}
function MonthDropdown({ state }: { state: CalendarState }) {
const months: Array<string> = [];
const formatter = useDateFormatter({
month: 'long',
timeZone: state.timeZone,
});
// Format the name of each month in the year according to the
// current locale and calendar system. Note that in some calendar
// systems, such as the Hebrew, the number of months may differ
// between years.
const numMonths = state.focusedDate.calendar.getMonthsInYear(
state.focusedDate,
);
for (let i = 1; i <= numMonths; i++) {
const date = state.focusedDate.set({ month: i });
months.push(formatter.format(date.toDate(state.timeZone)));
}
const onChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const value = Number(e.target.value);
const date = state.focusedDate.set({ month: value });
state.setFocusedDate(date);
};
return (
<NativeSelectField>
<Label className="sr-only">Month</Label>
<NativeSelect onChange={onChange} value={state.focusedDate.month}>
{months.map((month, i) => (
<option key={i} value={i + 1}>
{month}
</option>
))}
</NativeSelect>
</NativeSelectField>
);
}

View file

@ -0,0 +1,171 @@
import React, { ReactNode } from 'react';
import {
CheckboxRenderProps,
composeRenderProps,
Checkbox as RACCheckbox,
CheckboxGroup as RACCheckboxGroup,
CheckboxGroupProps as RACCheckboxGroupProps,
CheckboxProps as RACCheckboxProps,
} from 'react-aria-components';
import { groupBox } from './utils';
import { twMerge } from 'tailwind-merge';
import { DescriptionContext, DescriptionProvider } from './field';
import { CheckIcon, MinusIcon } from './icons';
export interface CheckboxGroupProps
extends Omit<RACCheckboxGroupProps, 'children'> {
children?: ReactNode;
orientation?: 'vertical' | 'horizontal';
}
export function CheckboxGroup({
orientation = 'vertical',
...props
}: CheckboxGroupProps) {
return (
<RACCheckboxGroup
{...props}
data-orientation={orientation}
className={composeRenderProps(props.className, (className) => {
return twMerge(groupBox, className);
})}
/>
);
}
export function Checkboxes({
className,
...props
}: React.JSX.IntrinsicElements['div']) {
return (
<div
data-ui="box"
className={twMerge(
'flex flex-col',
'group-data-[orientation=horizontal]:flex-row',
'group-data-[orientation=horizontal]:flex-wrap',
'has-data-[ui=description]:[&_label]:font-medium',
className,
)}
{...props}
/>
);
}
export function CheckboxField({
className,
...props
}: React.JSX.IntrinsicElements['div']) {
return (
<DescriptionProvider>
<div
{...props}
data-ui="field"
className={twMerge(
'group flex flex-col gap-y-1',
'has-[label[data-label-placement=start]]:[&_[data-ui=description]:not([class*=pe-])]:pe-16',
'has-[label[data-label-placement=end]]:[&_[data-ui=description]:not([class*=ps-])]:ps-7',
'has-data-[ui=description]:[&_label]:font-medium',
'has-[label[data-disabled]]:**:data-[ui=description]:opacity-50',
className,
)}
/>
</DescriptionProvider>
);
}
interface CheckboxProps extends RACCheckboxProps {
labelPlacement?: 'start' | 'end';
render?: never;
}
export interface CustomRenderCheckboxProps
extends Omit<RACCheckboxProps, 'children'> {
render:
| React.ReactElement
| ((props: CheckboxRenderProps) => React.ReactNode);
children?: never;
}
export function Checkbox(props: CheckboxProps | CustomRenderCheckboxProps) {
const descriptionContext = React.useContext(DescriptionContext);
if (props.render) {
const { render, ...restProps } = props;
return (
<RACCheckbox
{...restProps}
aria-describedby={descriptionContext?.['aria-describedby']}
className={composeRenderProps(
props.className,
(className, renderProps) => {
return twMerge([
'group',
'text-base/6 sm:text-sm/6',
renderProps.isDisabled && 'opacity-50',
renderProps.isFocusVisible &&
'flex outline-ring outline outline-2 outline-offset-2',
className,
])
}
)}
>
{render}
</RACCheckbox>
);
}
const { labelPlacement = 'end', ...restProps } = props;
return (
<RACCheckbox
{...restProps}
aria-describedby={descriptionContext?.['aria-describedby']}
data-label-placement={labelPlacement}
className={composeRenderProps(
props.className,
(className, renderProps) => {
return twMerge(
'group flex items-center text-base/6 group-data-[orientation=horizontal]:text-nowrap sm:text-sm/6',
labelPlacement === 'start' && 'flex-row-reverse justify-between',
renderProps.isDisabled && 'opacity-50',
className,
);
},
)}
>
{(renderProps) => {
return (
<>
<div
data-ui="checkbox"
className={twMerge([
'flex size-4.5 shrink-0 items-center justify-center rounded-sm border border-input sm:size-4',
labelPlacement === 'end' ? 'me-3' : 'ms-3',
renderProps.isReadOnly && 'opacity-50',
renderProps.isInvalid &&
'border-destructive dark:border-destructive',
(renderProps.isSelected || renderProps.isIndeterminate) &&
'border-accent bg-accent',
renderProps.isFocusVisible &&
'outline-ring outline outline-2 outline-offset-2',
])}
>
{renderProps.isIndeterminate ? (
<MinusIcon className="size-4 text-[lch(from_var(--color-accent)_calc((49.44_-_l)_*_infinity)_0_0)] sm:size-3.5" />
) : renderProps.isSelected ? (
<CheckIcon className="size-4 text-[lch(from_var(--color-accent)_calc((49.44_-_l)_*_infinity)_0_0)] sm:size-3.5" />
) : null}
</div>
{typeof props.children === 'function'
? props.children(renderProps)
: props.children}
</>
);
}}
</RACCheckbox>
);
}

View file

@ -0,0 +1,94 @@
import React from 'react';
import { Button, ButtonProps } from './button';
import { useCopyToClipboard } from './hooks/use-clipboard';
import { TooltipTrigger, Tooltip } from './tooltip';
import { CheckIcon, CopyIcon } from './icons';
import { twMerge } from 'tailwind-merge';
export type ClipboardProps = {
timeout?: number;
children: (payload: {
copied: boolean;
copy: (value: string) => void;
}) => React.ReactNode;
};
export function Clipboard({ timeout, children }: ClipboardProps) {
const { copied, copy } = useCopyToClipboard({ timeout });
return children({ copied, copy });
}
export function CopyButton({
copyValue,
label = 'Copy',
labelAfterCopied = 'Copied to clipboard',
icon,
variant = 'plain',
children,
...props
}: {
copyValue: string;
label?: string;
labelAfterCopied?: string;
icon?: React.JSX.Element;
} & ButtonProps) {
const [showTooltip, setShowTooltip] = React.useState(false);
return (
<Clipboard>
{({ copied, copy }) => {
return (
<TooltipTrigger isOpen={copied || showTooltip}>
<Button
variant={variant}
{...(!children && {
isIconOnly: true,
})}
aria-label={label}
{...props}
onHoverChange={setShowTooltip}
onPress={() => {
copy(copyValue);
setShowTooltip(false);
}}
>
{children ?? (
<>
{icon ? (
React.cloneElement(icon, {
className: twMerge(
'transition-all',
copied
? 'absolute scale-0 opacity-0'
: 'scale-100 opacity-100',
),
})
) : (
<CopyIcon
className={twMerge(
'transition-all',
copied
? 'absolute scale-0 opacity-0'
: 'scale-100 opacity-100',
)}
/>
)}
<CheckIcon
className={twMerge(
'text-success transition-all',
copied
? 'scale-100 opacity-100'
: 'absolute scale-0 opacity-0',
)}
/>
</>
)}
</Button>
<Tooltip>{copied ? labelAfterCopied : label}</Tooltip>
</TooltipTrigger>
);
}}
</Clipboard>
);
}

View file

@ -0,0 +1,154 @@
import React from 'react';
import {
ComboBox as RACComboBox,
ComboBoxProps as RACComboBoxProps,
ComboBoxStateContext,
GroupProps,
Group,
composeRenderProps,
} from 'react-aria-components';
import { ButtonProps, Button } from './button';
import { inputField } from './utils';
import { twMerge } from 'tailwind-merge';
import {
SelectListBox,
SelectListItem,
SelectListItemDescription,
SelectListItemLabel,
SelectPopover,
SelectSection,
} from './select';
import { Input } from './field';
import { ChevronDownIcon, XIcon } from './icons';
export function ComboBox(props: RACComboBoxProps<object>) {
return (
<RACComboBox
{...props}
data-ui="comboBox"
className={composeRenderProps(props.className, (className) =>
twMerge(['w-full min-w-56', inputField, className]),
)}
/>
);
}
export function ComboBoxGroup(props: GroupProps) {
return (
<Group
data-ui="control"
{...props}
className={composeRenderProps(props.className, (className) =>
twMerge([
'group/combobox',
'isolate',
'grid',
'grid-cols-[36px_1fr_minmax(40px,max-content)_minmax(40px,max-content)]',
'sm:grid-cols-[36px_1fr_minmax(36px,max-content)_minmax(36px,max-content)]',
'items-center',
// Icon
'sm:[&>[data-ui=icon]:has(+input)]:size-4',
'[&>[data-ui=icon]:has(+input)]:size-5',
'[&>[data-ui=icon]:has(+input)]:row-start-1',
'[&>[data-ui=icon]:has(+input)]:col-start-1',
'[&>[data-ui=icon]:has(+input)]:place-self-center',
'[&>[data-ui=icon]:has(+input)]:text-muted',
'[&>[data-ui=icon]:has(+input)]:z-10',
// Input
'[&>input]:row-start-1',
'[&>input]:col-span-full',
'[&>input:not([class*=pe-])]:pe-10',
'sm:[&>input:not([class*=pe-])]:pe-9',
'[&>input:has(+[data-ui=clear]:not(:last-of-type))]:pe-20',
'sm:[&>input:has(+[data-ui=clear]:not(:last-of-type))]:pe-16',
'[&:has([data-ui=icon]+input)>input]:ps-10',
'sm:[&:has([data-ui=icon]+input)>input]:ps-8',
// Trigger button
'*:data-[ui=trigger]:row-start-1',
'*:data-[ui=trigger]:-col-end-1',
'*:data-[ui=trigger]:place-self-center',
// Clear button
'*:data-[ui=clear]:row-start-1',
'*:data-[ui=clear]:-col-end-2',
'*:data-[ui=clear]:justify-self-end',
'[&>[data-ui=clear]:last-of-type]:-col-end-1',
'[&>[data-ui=clear]:last-of-type]:place-self-center',
className,
]),
)}
/>
);
}
export const ComboBoxInput = Input;
export function ComboBoxButton({
triggerIcon = <ChevronDownIcon />,
}: {
triggerIcon?: React.ReactNode;
}) {
return (
<Button
isIconOnly
size="sm"
data-ui="trigger"
variant="plain"
className="text-muted group-hover/combobox:text-foreground"
>
{triggerIcon}
</Button>
);
}
export function ComboBoxClearButton({
onPress,
}: {
onPress?: ButtonProps['onPress'];
}) {
const state = React.useContext(ComboBoxStateContext);
return (
<Button
className={twMerge(
'[&:not(:hover)]:text-muted',
'not-last:-me-1',
state?.inputValue
? 'visible focus-visible:-outline-offset-2'
: 'invisible',
)}
slot={null}
data-ui="clear"
size="sm"
isIconOnly
variant="plain"
onPress={(e) => {
state?.setSelectedKey(null);
onPress?.(e);
}}
>
<XIcon
aria-label="Clear"
className="size-4 sm:size-[calc(--spacing(4)-1px)]"
/>
</Button>
);
}
export const ComboBoxPopover = SelectPopover;
export const ComboBoxSection = SelectSection;
export const ComboBoxListBox = SelectListBox;
export const ComboBoxListItem = SelectListItem;
export const ComboBoxListItemLabel = SelectListItemLabel;
export const ComboBoxListItemDescription = SelectListItemDescription;

View file

@ -0,0 +1,73 @@
import {
DateField as RACDateField,
DateFieldProps as RACDateFieldProps,
DateInput as RACDateInput,
DateInputProps as RACDateInputProps,
DateSegment,
DateValue,
composeRenderProps,
} from 'react-aria-components';
import { inputField } from './utils';
import { twMerge } from 'tailwind-merge';
export interface DateFieldProps<T extends DateValue>
extends RACDateFieldProps<T> {}
export function DateField<T extends DateValue>(props: DateFieldProps<T>) {
return (
<RACDateField
{...props}
className={composeRenderProps(
props.className,
(className, { isDisabled }) => {
return twMerge(
inputField,
// RAC does not set disable to date field when it is disable
// So we have to style disable state for none input
isDisabled && '[&>:not(input)]:opacity-50',
className,
);
},
)}
/>
);
}
export type DateInputProps = Omit<RACDateInputProps, 'children'>;
export function DateInput(props: DateInputProps) {
return (
<RACDateInput
{...props}
data-ui="control"
className={composeRenderProps(props.className, (className, renderProps) =>
twMerge(
'group flex w-full items-center rounded-md border border-input bg-transparent',
'[&:has([data-disabled=true])]:opacity-50',
'[&:has([data-ui=date-segment][aria-readonly])]:bg-zinc-50',
'dark:[&:has([data-ui=date-segment][aria-readonly])]:bg-white/10',
'block min-w-[150px]',
'text-base/6 sm:text-sm/6',
'px-3',
'py-[calc(--spacing(2.5)-1px)] sm:py-[calc(--spacing(1.5)-1px)]',
renderProps.isInvalid && 'border-destructive',
renderProps.isFocusWithin && 'border-ring ring-1 ring-ring',
className,
),
)}
>
{(segment) => (
<DateSegment
data-ui="date-segment"
segment={segment}
className={twMerge(
'inline rounded-sm px-0.5 caret-transparent outline-0 data-[type=literal]:px-0',
'data-placeholder:italic data-placeholder:text-muted',
'focus:bg-accent focus:text-[lch(from_var(--color-accent)_calc((49.44_-_l)_*_infinity)_0_0)] focus:data-placeholder:text-[lch(from_var(--color-accent)_calc((49.44_-_l)_*_infinity)_0_0)]',
)}
/>
)}
</RACDateInput>
);
}

View file

@ -0,0 +1,128 @@
import React from 'react';
import {
DatePicker as RACDatePicker,
DatePickerProps as RACDatePickerProps,
DateValue,
DatePickerStateContext,
useLocale,
Group,
composeRenderProps,
} from 'react-aria-components';
import { Button } from './button';
import { Calendar, YearRange } from './calendar';
import { DateInput, DateInputProps } from './date-field';
import { Dialog } from './dialog';
import { Popover } from './popover';
import { inputField } from './utils';
import { twMerge } from 'tailwind-merge';
import { CalendarIcon } from './icons';
export interface DatePickerProps<T extends DateValue>
extends RACDatePickerProps<T> {}
export function DatePicker<T extends DateValue>(props: DatePickerProps<T>) {
return (
<RACDatePicker
{...props}
className={composeRenderProps(props.className, (className) => {
return twMerge(inputField, className);
})}
/>
);
}
export function DatePickerInput({
yearRange,
...props
}: DateInputProps & { yearRange?: YearRange }) {
return (
<>
<Group
data-ui="control"
{...props}
className={[
'group',
'grid w-auto min-w-52',
'grid-cols-[1fr_calc(theme(size.5)+20px)]',
'sm:grid-cols-[1fr_calc(theme(size.4)+20px)]',
].join(' ')}
>
<DateInput
{...props}
className={composeRenderProps(props.className, (className) =>
twMerge(
'col-span-full',
'row-start-1',
'sm:pe-9',
'pe-10',
className,
),
)}
/>
<Button
variant="plain"
size="sm"
isIconOnly
data-ui="trigger"
className={[
'focus-visible:-outline-offset-1',
'row-start-1',
'-col-end-1',
'place-self-center',
'text-muted group-hover:text-foreground',
].join(' ')}
>
<CalendarIcon />
</Button>
</Group>
<Popover placement="bottom" className="rounded-xl">
<Dialog>
<Calendar yearRange={yearRange} />
</Dialog>
</Popover>
</>
);
}
export function DatePickerButton({
className,
children,
}: {
className?: string;
children?: React.ReactNode;
}) {
const { locale } = useLocale();
const state = React.useContext(DatePickerStateContext);
const formattedDate = state?.formatValue(locale, {});
return (
<>
<Group data-ui="control">
<Button
className={twMerge(
'border-input w-full min-w-52 flex-1 justify-between px-3 leading-6 font-normal',
className,
)}
variant="outline"
>
{formattedDate === '' ? (
<span className="text-muted">{children}</span>
) : (
<span>{formattedDate}</span>
)}
<CalendarIcon className="text-muted group-hover:text-foreground" />
</Button>
<DateInput className="hidden" aria-hidden />
</Group>
<Popover placement="bottom" className="rounded-xl">
<Dialog>
<Calendar />
</Dialog>
</Popover>
</>
);
}

View file

@ -0,0 +1,161 @@
import React from 'react';
import {
DateRangePicker as AriaDateRangePicker,
DateRangePickerProps as AriaDateRangePickerProps,
DateRangePickerStateContext,
DateValue,
useLocale,
Group,
} from 'react-aria-components';
import { Button } from './button';
import { DateInput } from './date-field';
import { Dialog } from './dialog';
import { Popover } from './popover';
import { RangeCalendar } from './range-calendar';
import { composeTailwindRenderProps, inputField } from './utils';
import { twMerge } from 'tailwind-merge';
import { CalendarIcon } from './icons';
export interface DateRangePickerProps<T extends DateValue>
extends AriaDateRangePickerProps<T> {}
export function DateRangePicker<T extends DateValue>({
...props
}: DateRangePickerProps<T>) {
return (
<AriaDateRangePicker
{...props}
className={composeTailwindRenderProps(props.className, inputField)}
/>
);
}
export function DateRangePickerInput() {
const { locale } = useLocale();
const state = React.useContext(DateRangePickerStateContext);
const formattedValue = state?.formatValue(locale, {});
return (
<>
<Group
data-ui="control"
className={({ isFocusWithin }) =>
twMerge(
'[&:has([aria-valuetext=Empty]:) w-full',
'grid grid-cols-[max-content_16px_max-content_1fr] items-center',
'group border-input relative rounded-md border',
'group-data-invalid:border-destructive',
'[&:has(_input[data-disabled=true])]:border-border/50',
'[&:has([data-ui=date-segment][aria-readonly])]:bg-zinc-50',
'dark:[&:has([data-ui=date-segment][aria-readonly])]:bg-white/10',
formattedValue ? 'min-w-60' : 'min-w-[278px]',
isFocusWithin &&
'border-ring ring-ring group-data-invalid:border-ring ring-1',
)
}
>
<DateInput
slot="start"
className={[
'flex min-w-fit border-none focus-within:ring-0',
'[&:has([data-ui=date-segment][aria-readonly])]:bg-transparent',
'dark:[&:has([data-ui=date-segment][aria-readonly])]:bg-transparent',
].join(' ')}
/>
<span
aria-hidden="true"
className="text-muted place-self-center group-data-disabled:opacity-50"
>
</span>
<DateInput
slot="end"
className={[
'flex min-w-fit flex-1 border-none opacity-100 focus-within:ring-0',
'[&:has([data-ui=date-segment][aria-readonly])]:bg-transparent',
'dark:[&:has([data-ui=date-segment][aria-readonly])]:bg-transparent',
].join(' ')}
/>
<Button
variant="plain"
isIconOnly
size="sm"
className="text-muted group-hover:text-foreground me-1 justify-self-end focus-visible:-outline-offset-1"
>
<CalendarIcon />
</Button>
</Group>
<Popover placement="bottom" className="rounded-xl">
<Dialog>
<RangeCalendar />
</Dialog>
</Popover>
</>
);
}
export function DateRangePickerButton({
className,
children,
}: {
className?: string;
children?: React.ReactNode;
}) {
const { locale } = useLocale();
const state = React.useContext(DateRangePickerStateContext);
const formattedValue = state?.formatValue(locale, {});
return (
<>
<Group data-ui="control">
<Button
variant="outline"
className={twMerge(
'border-input w-full min-w-64 px-0 font-normal sm:px-0',
className,
)}
>
<div
className={twMerge(
'grid w-full items-center',
formattedValue
? 'grid grid-cols-[1fr_16px_1fr_36px]'
: 'grid-cols-[1fr_36px]',
)}
>
{formattedValue ? (
<>
<span className="min-w-fit px-3 text-base/6 sm:text-sm/6">
{formattedValue.start}
</span>
<span
aria-hidden="true"
className="text-muted place-self-center group-data-disabled:opacity-50"
>
</span>
<span className="min-w-fit px-3 text-base/6 sm:text-sm/6">
{formattedValue.end}
</span>
</>
) : (
<span className="text-muted justify-self-start px-3">
{children}
</span>
)}
<CalendarIcon className="text-muted group-hover:text-foreground place-self-center" />
</div>
</Button>
<DateInput slot="start" aria-hidden className="hidden" />
<DateInput slot="end" aria-hidden className="hidden" />
</Group>
<Popover placement="bottom" className="rounded-xl">
<Dialog>
<RangeCalendar />
</Dialog>
</Popover>
</>
);
}

View file

@ -0,0 +1,191 @@
import {
DialogProps as RACDialogProps,
Dialog as RACDialog,
composeRenderProps,
} from 'react-aria-components';
import { twMerge } from 'tailwind-merge';
import React from 'react';
import { BaseHeadingProps, Heading } from './heading';
import { Button, ButtonProps } from './button';
import { Text } from './text';
import { XIcon } from './icons';
export { DialogTrigger } from 'react-aria-components';
export interface DialogProps extends RACDialogProps {
alert?: boolean;
}
export function Dialog({ role, alert = false, ...props }: DialogProps) {
return (
<RACDialog
{...props}
role={role ?? alert ? 'alertdialog' : 'dialog'}
className={twMerge(
'relative flex max-h-[inherit] flex-col overflow-auto outline-hidden [&:has([data-ui=dialog-body])]:overflow-hidden',
'[&:not(:has([data-ui=dialog-header]))>[data-ui=dialog-body]:not([class*=pt-])]:pt-6',
'[&:not(:has([data-ui=dialog-footer]))>[data-ui=dialog-body]:not([class*=pt-])]:pb-6',
props.className,
)}
/>
);
}
type DialogHeaderProps = BaseHeadingProps;
export const DialogTitle = React.forwardRef<
HTMLHeadingElement,
DialogHeaderProps
>(function DialogTitle({ level = 2, ...props }, ref) {
return <Heading {...props} ref={ref} slot="title" level={level} />;
});
export function DialogHeader({ className, ...props }: DialogHeaderProps) {
const headerRef = React.useRef<HTMLHeadingElement>(null);
React.useEffect(() => {
const header = headerRef.current;
if (!header) {
return;
}
const observer = new ResizeObserver((entries) => {
for (const entry of entries) {
header.parentElement?.style.setProperty(
'--dialog-header-height',
`${entry.target.clientHeight}px`,
);
}
});
observer.observe(header);
return () => {
observer.unobserve(header);
};
}, []);
return React.Children.toArray(props.children).every(
(child) => typeof child === 'string',
) ? (
<DialogTitle
{...props}
data-ui="dialog-header"
ref={headerRef}
className={twMerge('ps-6 pe-10 pt-6 pb-2', className)}
/>
) : (
<div
ref={headerRef}
data-ui="dialog-header"
className={twMerge(
'relative flex w-full flex-col ps-6 pe-10 pt-6 pb-2',
className,
)}
{...props}
>
{props.children}
</div>
);
}
export function DialogBody({
className,
children,
...props
}: React.JSX.IntrinsicElements['div']) {
return (
<div
{...props}
data-ui="dialog-body"
className={twMerge(
'flex flex-1 flex-col overflow-auto px-6',
'max-h-[calc(var(--visual-viewport-height)-var(--visual-viewport-vertical-padding)-var(--dialog-header-height,0px)-var(--dialog-footer-height,0px))]',
className,
)}
>
{React.Children.toArray(children).every(
(child) => typeof child === 'string',
) ? (
<Text>{children}</Text>
) : (
children
)}
</div>
);
}
export function DialogFooter({
className,
...props
}: React.JSX.IntrinsicElements['div']) {
const footerRef = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
const footer = footerRef.current;
if (!footer) {
return;
}
const observer = new ResizeObserver((entries) => {
for (const entry of entries) {
footer.parentElement?.style.setProperty(
'--dialog-footer-height',
`${entry.target.clientHeight}px`,
);
}
});
observer.observe(footer);
return () => {
observer.unobserve(footer);
};
}, []);
return (
<div
{...props}
data-ui="dialog-footer"
ref={footerRef}
className={twMerge(
'mt-auto flex flex-col flex-col-reverse justify-end gap-3 p-6 sm:flex-row',
className,
)}
/>
);
}
export function DialogCloseButton({
variant = 'plain',
...props
}: ButtonProps) {
if (props.children) {
return <Button {...props} slot="close" variant={variant} />;
}
const {
size = 'lg',
'aria-label': ariaLabel,
isIconOnly = true,
...restProps
} = props;
return (
<Button
{...restProps}
slot="close"
isIconOnly={isIconOnly}
variant={variant}
size={size}
className={composeRenderProps(props.className, (className) =>
twMerge(
'text-muted/75 hover:text-foreground absolute end-2 top-3 p-1.5',
className,
),
)}
>
<XIcon aria-label={ariaLabel ?? 'Close'} />
</Button>
);
}

View file

@ -0,0 +1,66 @@
import React from 'react';
import {
Button,
ButtonProps,
composeRenderProps,
DisclosureGroupProps,
DisclosurePanelProps,
DisclosureGroup as RACDisclosureGroup,
DisclosurePanel as RACDisclosurePanel,
} from 'react-aria-components';
import { twMerge } from 'tailwind-merge';
import { Text } from './text';
export { Disclosure } from 'react-aria-components';
export function DisclosureGroup(props: DisclosureGroupProps) {
return (
<RACDisclosureGroup
{...props}
className={composeRenderProps(props.className, (className) => {
return twMerge([
'flex flex-col [&>div:has(>button[aria-expanded]):not([class*=pb-]):not(:last-child)]:pb-4 [&>div:has(>button[aria-expanded]):not([class*=pt-]):not(:first-of-type)]:pt-4',
className,
]);
})}
/>
);
}
export function DisclosurePanel({ children, ...props }: DisclosurePanelProps) {
return (
<RACDisclosurePanel {...props}>
{React.Children.toArray(children).every(
(child) => typeof child === 'string',
) ? (
<Text>{children}</Text>
) : (
children
)}
</RACDisclosurePanel>
);
}
export function DisclosureControl(props: ButtonProps) {
return (
<Button
{...props}
slot="trigger"
className={composeRenderProps(
props.className,
(className, { isFocusVisible }) => {
return twMerge([
'group [&_svg[data-ui=icon]:not(:hover)]:text-muted mb-2 flex items-center gap-x-2 rounded-sm outline-hidden [&_svg[data-ui=icon]:not([class*=size-])]:size-5',
isFocusVisible && [
'outline',
'outline-2',
'outline-ring',
'outline-offset-2',
],
className,
]);
},
)}
/>
);
}

View file

@ -0,0 +1,28 @@
import {
composeRenderProps,
DropZoneProps,
DropZone as RACDropZone,
} from 'react-aria-components';
import { twMerge } from 'tailwind-merge';
export function DropZone(props: DropZoneProps) {
return (
<RACDropZone
{...props}
className={composeRenderProps(
props.className,
(className, { isDropTarget, isDisabled, isFocusVisible }) =>
twMerge(
'sm:min-w-96',
'flex shrink-0 flex-col items-center justify-center rounded-md',
'border-input border border-dashed p-2',
isDisabled && 'opacity-50',
isDropTarget && 'bg-accent/15 dark:bg-accent/75',
(isDropTarget || isFocusVisible) &&
'border-ring ring-ring border-solid ring-1',
className,
),
)}
/>
);
}

View file

@ -0,0 +1,76 @@
import { twMerge } from 'tailwind-merge';
import { TextProps } from 'react-aria-components';
import { Text } from './text';
import { Heading, HeadingProps } from './heading';
export function EmptyState({
className,
...props
}: React.JSX.IntrinsicElements['div']) {
return (
<div
{...props}
className={twMerge(
'flex h-full w-full flex-col items-center justify-center gap-1 p-4 text-center @container',
className,
)}
/>
);
}
export function EmptyStateIcon({
className,
children,
...props
}: React.JSX.IntrinsicElements['div']) {
return (
<div
{...props}
className={twMerge(
'mb-2 flex max-w-32 items-center justify-center @md:max-w-40',
'[&>svg:not([class*=text-])]:text-muted [&>svg]:h-auto [&>svg]:min-w-12 [&>svg]:max-w-full',
className,
)}
>
{children}
</div>
);
}
export function EmptyStateHeading({
className,
level = 2,
...props
}: HeadingProps) {
return (
<Heading
{...props}
level={level}
className={twMerge('text-balance', className)}
/>
);
}
export function EmptyStateDescription({ className, ...props }: TextProps) {
return (
<Text
{...props}
className={twMerge('max-w-prose text-balance', className)}
/>
);
}
export function EmptyStateActions({
className,
...props
}: React.JSX.IntrinsicElements['div']) {
return (
<div
{...props}
className={twMerge(
'mt-3 flex flex-col items-center justify-center gap-4 p-2',
className,
)}
/>
);
}

View file

@ -0,0 +1,200 @@
import React from 'react';
import {
FieldErrorProps,
InputProps,
LabelProps,
FieldError as RACFieldError,
Input as RACInput,
Label as RACLabel,
TextProps,
LabelContext,
GroupContext,
TextFieldProps as RACTextFieldProps,
TextField as RACTextField,
TextArea as RACTextArea,
TextAreaProps as RACTextAreaProps,
Text as RACText,
composeRenderProps,
} from 'react-aria-components';
import { twMerge } from 'tailwind-merge';
import { DisplayLevel, displayLevels, inputField } from './utils';
import { Text } from './text';
// https://react-spectrum.adobe.com/react-aria/Group.html#advanced-customization
export function LabeledGroup({
className,
children,
}: {
className?: string;
children: React.ReactNode;
}) {
const labelId = React.useId();
return (
<LabelContext.Provider value={{ id: labelId, elementType: 'span' }}>
<GroupContext.Provider value={{ 'aria-labelledby': labelId }}>
<div
className={twMerge(
['[&>[data-ui=label]:first-of-type:not([class*=mb])]:mb-2'],
className,
)}
>
{children}
</div>
</GroupContext.Provider>
</LabelContext.Provider>
);
}
export function Label({
requiredHint,
displayLevel = 3,
...props
}: LabelProps & {
requiredHint?: boolean;
displayLevel?: DisplayLevel;
}) {
return (
<RACLabel
{...props}
data-ui="label"
className={twMerge(
'inline-block min-w-max text-pretty',
'group-disabled:opacity-50',
displayLevels[displayLevel],
requiredHint &&
"after:text-destructive after:ms-0.5 after:content-['*']",
props.className,
)}
/>
);
}
export const DescriptionContext = React.createContext<{
'aria-describedby'?: string;
} | null>(null);
export function DescriptionProvider({
children,
}: {
children: React.ReactNode;
}) {
const descriptionId: string | null = React.useId();
const [descriptionRendered, setDescriptionRendered] = React.useState(true);
React.useLayoutEffect(() => {
if (!document.getElementById(descriptionId)) {
setDescriptionRendered(false);
}
}, [descriptionId]);
return (
<DescriptionContext.Provider
value={{
'aria-describedby': descriptionRendered ? descriptionId : undefined,
}}
>
{children}
</DescriptionContext.Provider>
);
}
/**
* RAC will auto associate <RACText slot="description"/> with TextField/NumberField/RadioGroup/CheckboxGroup/DatePicker etc,
* but not for Switch/Checkbox/Radio and our custom components. We use follow pattern to associate description for
* Switch/Checkbox/Radio https://react-spectrum.adobe.com/react-aria/Switch.html#advanced-customization
*/
export function Description({ className, ...props }: TextProps) {
const describedby =
React.useContext(DescriptionContext)?.['aria-describedby'];
return describedby ? (
<Text
{...props}
id={describedby}
data-ui="description"
className={twMerge('block group-disabled:opacity-50', className)}
/>
) : (
<RACText
{...props}
data-ui="description"
slot="description"
className={twMerge(
'text-muted block text-base/6 text-pretty sm:text-sm/6',
'group-disabled:opacity-50',
className,
)}
/>
);
}
export function TextField(props: RACTextFieldProps) {
return (
<RACTextField
{...props}
data-ui="text-field"
className={composeRenderProps(props.className, (className) =>
twMerge(inputField, className),
)}
/>
);
}
export function FieldError(props: FieldErrorProps) {
return (
<RACFieldError
{...props}
data-ui="errorMessage"
className={composeRenderProps(props.className, (className) =>
twMerge('text-destructive block text-base/6 sm:text-sm/6', className),
)}
/>
);
}
export const Input = React.forwardRef<HTMLInputElement, InputProps>(
function Input(props, ref) {
return (
<RACInput
{...props}
ref={ref}
className={composeRenderProps(
props.className,
(className, renderProps) =>
twMerge(
'border-input w-full rounded-md border outline-hidden',
'px-3 py-[calc(--spacing(2.5)-1px)] sm:py-[calc(--spacing(1.5)-1px)]',
'placeholder:text-muted text-base/6 sm:text-sm/6',
'[&[readonly]]:bg-zinc-50',
'dark:[&[readonly]]:bg-white/10',
renderProps.isDisabled && 'opacity-50',
renderProps.isInvalid && 'border-destructive',
renderProps.isFocused && 'border-ring ring-ring ring-1',
className,
),
)}
/>
);
},
);
export function TextArea(props: RACTextAreaProps) {
return (
<RACTextArea
{...props}
className={composeRenderProps(props.className, (className, renderProps) =>
twMerge(
'border-input w-full rounded-md border px-3 py-1 outline-hidden',
'placeholder:text-muted text-base/6 sm:text-sm/6',
'[&[readonly]]:bg-zinc-50',
'dark:[&[readonly]]:bg-white/10',
renderProps.isDisabled && 'opacity-50',
renderProps.isInvalid && 'border-destructive',
renderProps.isFocused && 'border-ring ring-ring ring-1',
className,
),
)}
/>
);
}

View file

@ -0,0 +1 @@
export { FileTrigger } from 'react-aria-components';

View file

@ -0,0 +1,11 @@
import {FormProps, Form as RACForm} from 'react-aria-components';
import {twMerge} from 'tailwind-merge';
export function Form(props: FormProps) {
return (
<RACForm
{...props}
className={twMerge("max-w-4xl space-y-6", props.className)}
/>
);
}

View file

@ -0,0 +1,74 @@
import {
GridList as AriaGridList,
GridListItem as AriaGridListItem,
Button,
composeRenderProps,
GridListItemProps,
GridListProps,
} from 'react-aria-components';
import { Checkbox } from './checkbox';
import { composeTailwindRenderProps} from './utils';
import { twMerge } from 'tailwind-merge';
export function GridList<T extends object>({
children,
...props
}: GridListProps<T>) {
return (
<AriaGridList
{...props}
className={composeTailwindRenderProps(
props.className,
'relative overflow-auto rounded-md border p-1',
)}
>
{children}
</AriaGridList>
);
}
export function GridListItem({ children, ...props }: GridListItemProps) {
const textValue = typeof children === 'string' ? children : undefined;
return (
<AriaGridListItem
{...props}
textValue={textValue}
className={composeRenderProps(
props.className,
(className, { isFocusVisible, isSelected, isDisabled, isHovered }) =>
twMerge(
'relative -mb-px flex cursor-default select-none gap-3 rounded-md px-2 py-1.5 text-sm outline-hidden',
'not-last:mb-0.5',
isHovered && ['bg-zinc100 dark:bg-zinc-800'],
isSelected && ['z-20'],
isDisabled && ['opacity-50'],
isFocusVisible && [
'outline',
'outline-2',
'-outline-offset-2',
'outline-ring',
],
className,
),
)}
>
{(renderProps) =>
typeof children === 'function' ? (
children(renderProps)
) : (
<>
{/* Add elements for drag and drop and selection. */}
{renderProps.allowsDragging && <Button slot="drag"></Button>}
{renderProps.selectionMode === 'multiple' &&
renderProps.selectionBehavior === 'toggle' && (
<Checkbox slot="selection" />
)}
{children}
</>
)
}
</AriaGridListItem>
);
}

View file

@ -0,0 +1,61 @@
import React from 'react';
import {
Heading as RACHeading,
HeadingProps as RACHeadingProps,
} from 'react-aria-components';
import { twMerge } from 'tailwind-merge';
import { DisplayLevel, displayLevels } from './utils';
export type BaseHeadingProps = {
level?: DisplayLevel;
elementType?: never;
} & RACHeadingProps;
type CustomElement = {
level?: never;
elementType: 'div';
} & React.JSX.IntrinsicElements['div'];
export type HeadingProps = {
displayLevel?: DisplayLevel;
} & (BaseHeadingProps | CustomElement);
export const Heading = React.forwardRef<
HTMLHeadingElement | HTMLDivElement,
HeadingProps
>(function Heading({ elementType, ...props }, ref) {
if (elementType) {
const { displayLevel = 1, className, ...restProps } = props;
return (
<div
{...restProps}
ref={ref}
className={twMerge(displayLevels[displayLevel], className)}
/>
);
}
const { level = 1, displayLevel, className, ...restProps } = props;
return (
<RACHeading
{...restProps}
ref={ref}
level={level}
className={twMerge(displayLevels[displayLevel ?? level], className)}
/>
);
});
export const SubHeading = React.forwardRef<
HTMLDivElement,
React.JSX.IntrinsicElements['div']
>(function SubHeading({ className, ...props }, ref) {
return (
<div
{...props}
ref={ref}
className={twMerge('text-muted mt-2 text-base sm:text-sm/6', className)}
/>
);
});

View file

@ -0,0 +1,35 @@
/**
* From https://github.com/mantinedev/mantine/blob/master/packages/%40mantine/hooks/src/use-clipboard/use-clipboard.ts
*/
import React from 'react';
export function useCopyToClipboard({ timeout = 2000 } = {}) {
const [error, setError] = React.useState<Error | null>(null);
const [copied, setCopied] = React.useState(false);
const [copyTimeout, setCopyTimeout] = React.useState<number | null>(null);
const handleCopyResult = (value: boolean) => {
window.clearTimeout(copyTimeout!);
setCopyTimeout(window.setTimeout(() => setCopied(false), timeout));
setCopied(value);
};
const copy = (valueToCopy: any) => {
if ('clipboard' in navigator) {
navigator.clipboard
.writeText(valueToCopy)
.then(() => handleCopyResult(true))
.catch((err) => setError(err));
} else {
setError(new Error('useCopyToClipboard: navigator.clipboard is not supported'));
}
};
const reset = () => {
setCopied(false);
setError(null);
window.clearTimeout(copyTimeout!);
};
return { copy, reset, error, copied };
}

View file

@ -0,0 +1,34 @@
import React from 'react';
type ImageLoadingStatus = 'idle' | 'loading' | 'loaded' | 'error';
export function useImageLoadingStatus(src?: string) {
const [loadingStatus, setLoadingStatus] =
React.useState<ImageLoadingStatus>('idle');
React.useLayoutEffect(() => {
if (!src) {
setLoadingStatus('error');
return;
}
let isMounted = true;
const image = new window.Image();
const updateStatus = (status: ImageLoadingStatus) => () => {
if (!isMounted) return;
setLoadingStatus(status);
};
setLoadingStatus('loading');
image.onload = updateStatus('loaded');
image.onerror = updateStatus('error');
image.src = src;
return () => {
isMounted = false;
};
}, [src]);
return loadingStatus;
}

View file

@ -0,0 +1,160 @@
import React from 'react';
import {
useFloating,
autoUpdate,
offset,
flip,
shift,
useDismiss,
useRole,
useInteractions,
FloatingFocusManager,
useHover,
safePolygon,
Placement,
ReferenceType,
} from '@floating-ui/react';
import { Heading, HeadingProps } from './heading';
import { twMerge } from 'tailwind-merge';
interface PopoverOptions {
placement?: Placement;
modal?: boolean;
}
function useHoverCard({ placement = 'bottom', modal }: PopoverOptions = {}) {
const [isOpen, setIsOpen] = React.useState(false);
const labelId = React.useId();
const data = useFloating({
placement,
open: isOpen,
onOpenChange: setIsOpen,
middleware: [
offset(10),
flip({ fallbackAxisSideDirection: 'end' }),
shift(),
],
whileElementsMounted: autoUpdate,
});
const context = data.context;
const dismiss = useDismiss(context);
const role = useRole(context);
const hover = useHover(context, {
handleClose: safePolygon(),
delay: 250,
});
const interactions = useInteractions([dismiss, role, hover]);
return React.useMemo(
() => ({
isOpen,
setIsOpen,
...interactions,
...data,
modal,
labelId,
}),
[isOpen, interactions, data, modal, labelId],
);
}
type ContextType = ReturnType<typeof useHoverCard> | null;
const HoverCardContext = React.createContext<ContextType>(null);
const useHoverCardContext = () => {
const context = React.useContext(HoverCardContext);
if (context == null) {
throw new Error('HoverCard components must be wrapped in <HoverCard />');
}
return context;
};
export function HoverCard({
children,
modal = false,
...restOptions
}: {
children: React.ReactNode;
} & PopoverOptions) {
const popover = useHoverCard({ modal, ...restOptions });
return (
<HoverCardContext.Provider value={popover}>
{children}
</HoverCardContext.Provider>
);
}
export function HoverCardTrigger({ children }: { children: React.ReactNode }) {
const context = useHoverCardContext();
const child = React.Children.only(children);
return React.cloneElement(
child as React.ReactElement<{
ref: ((node: ReferenceType | null) => void) &
((node: ReferenceType | null) => void);
}>,
{
ref: context.refs.setReference,
...context.getReferenceProps(),
},
);
}
export function HoverCardContent({
children,
label,
className,
}: {
children:
| React.ReactNode
| (({ close }: { close: () => void }) => React.ReactNode);
} & {
label?: string;
className?: string;
}) {
const {
labelId,
context: floatingContext,
setIsOpen,
isOpen,
modal,
refs,
floatingStyles,
getFloatingProps,
} = useHoverCardContext();
const aria = label ? { 'aria-label': label } : { 'aria-labelledby': labelId };
return (
isOpen && (
<FloatingFocusManager context={floatingContext} modal={modal}>
<div
className={twMerge(
'bg-background max-w-72 rounded-lg p-1 ring-1 shadow-lg ring-zinc-950/10 outline-hidden dark:bg-zinc-800 dark:ring-white/15',
className,
)}
ref={refs.setFloating}
style={floatingStyles}
{...getFloatingProps()}
{...aria}
>
{typeof children === 'function'
? children({ close: () => setIsOpen(false) })
: children}
</div>
</FloatingFocusManager>
)
);
}
export function HoverCardHeader(props: HeadingProps) {
const { labelId } = useHoverCardContext();
return <Heading {...props} id={labelId}></Heading>;
}

View file

@ -0,0 +1,35 @@
import React from 'react';
interface IconProps
extends Omit<React.JSX.IntrinsicElements['svg'], 'aria-hidden'> {
children: React.ReactNode;
}
// See: https://www.radix-ui.com/themes/docs/components/accessible-icon
export function Icon({
children,
'aria-label': ariaLabel,
...props
}: IconProps) {
const child = React.Children.only(children);
return (
<>
{React.cloneElement(
child as React.ReactElement<
React.JSX.IntrinsicElements['svg'] & {
'data-ui'?: string;
}
>,
{
...props,
'aria-hidden': 'true',
'aria-label': undefined,
'data-ui': 'icon',
focusable: 'false',
},
)}
{ariaLabel ? <span className="sr-only">{ariaLabel}</span> : null}
</>
);
}

View file

@ -0,0 +1,538 @@
import { twMerge } from 'tailwind-merge';
import { Icon } from './icon';
export function EyeIcon({
'aria-label': arialLabel,
...props
}: React.JSX.IntrinsicElements['svg']) {
return (
<Icon aria-label={arialLabel}>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
{...props}
>
<path d="M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6Z" />
<path
fillRule="evenodd"
d="M1.323 11.447C2.811 6.976 7.028 3.75 12.001 3.75c4.97 0 9.185 3.223 10.675 7.69.12.362.12.752 0 1.113-1.487 4.471-5.705 7.697-10.677 7.697-4.97 0-9.186-3.223-10.675-7.69a1.762 1.762 0 0 1 0-1.113ZM17.25 12a5.25 5.25 0 1 1-10.5 0 5.25 5.25 0 0 1 10.5 0Z"
clipRule="evenodd"
/>
</svg>
</Icon>
);
}
export function EyeOffIcon({
'aria-label': arialLabel,
...props
}: React.JSX.IntrinsicElements['svg']) {
return (
<Icon aria-label={arialLabel}>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
{...props}
>
<path
fillRule="evenodd"
d="M3.28 2.22a.75.75 0 0 0-1.06 1.06l14.5 14.5a.75.75 0 1 0 1.06-1.06l-1.745-1.745a10.029 10.029 0 0 0 3.3-4.38 1.651 1.651 0 0 0 0-1.185A10.004 10.004 0 0 0 9.999 3a9.956 9.956 0 0 0-4.744 1.194L3.28 2.22ZM7.752 6.69l1.092 1.092a2.5 2.5 0 0 1 3.374 3.373l1.091 1.092a4 4 0 0 0-5.557-5.557Z"
clipRule="evenodd"
/>
<path d="m10.748 13.93 2.523 2.523a9.987 9.987 0 0 1-3.27.547c-4.258 0-7.894-2.66-9.337-6.41a1.651 1.651 0 0 1 0-1.186A10.007 10.007 0 0 1 2.839 6.02L6.07 9.252a4 4 0 0 0 4.678 4.678Z" />
</svg>
</Icon>
);
}
export function CheckIcon({
'aria-label': arialLabel,
...props
}: React.JSX.IntrinsicElements['svg']) {
return (
<Icon aria-label={arialLabel}>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
strokeWidth="2"
{...props}
>
<path
fillRule="evenodd"
d="M16.704 4.153a.75.75 0 0 1 .143 1.052l-8 10.5a.75.75 0 0 1-1.127.075l-4.5-4.5a.75.75 0 0 1 1.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 0 1 1.05-.143Z"
clipRule="evenodd"
/>
</svg>
</Icon>
);
}
export function CircleInfoIcon({
'aria-label': arialLabel,
...props
}: React.JSX.IntrinsicElements['svg']) {
return (
<Icon aria-label={arialLabel}>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
{...props}
>
<circle cx="12" cy="12" r="10" />
<path d="M12 16v-4" />
<path d="M12 8h.01" />
</svg>
</Icon>
);
}
export function CircleCheckIcon({
'aria-label': arialLabel,
...props
}: React.JSX.IntrinsicElements['svg']) {
return (
<Icon aria-label={arialLabel}>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
{...props}
>
<circle cx="12" cy="12" r="10" />
<path d="m9 12 2 2 4-4" />
</svg>
</Icon>
);
}
export function OctagonAlertIcon({
'aria-label': arialLabel,
...props
}: React.JSX.IntrinsicElements['svg']) {
return (
<Icon aria-label={arialLabel}>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
{...props}
>
<path d="M12 16h.01" />
<path d="M12 8v4" />
<path d="M15.312 2a2 2 0 0 1 1.414.586l4.688 4.688A2 2 0 0 1 22 8.688v6.624a2 2 0 0 1-.586 1.414l-4.688 4.688a2 2 0 0 1-1.414.586H8.688a2 2 0 0 1-1.414-.586l-4.688-4.688A2 2 0 0 1 2 15.312V8.688a2 2 0 0 1 .586-1.414l4.688-4.688A2 2 0 0 1 8.688 2z" />
</svg>
</Icon>
);
}
export function CircleXIcon({
'aria-label': arialLabel,
...props
}: React.JSX.IntrinsicElements['svg']) {
return (
<Icon aria-label={arialLabel}>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
{...props}
>
<circle cx="12" cy="12" r="10" />
<path d="m15 9-6 6" />
<path d="m9 9 6 6" />
</svg>
</Icon>
);
}
export function PlusIcon({
'aria-label': arialLabel,
...props
}: React.JSX.IntrinsicElements['svg']) {
return (
<Icon aria-label={arialLabel}>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
{...props}
>
<path d="M5 12h14" />
<path d="M12 5v14" />
</svg>
</Icon>
);
}
export function MinusIcon({
'aria-label': arialLabel,
...props
}: React.JSX.IntrinsicElements['svg']) {
return (
<Icon aria-label={arialLabel}>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
{...props}
>
<path d="M5 12h14" />
</svg>
</Icon>
);
}
export function XIcon({
'aria-label': arialLabel,
...props
}: React.JSX.IntrinsicElements['svg']) {
return (
<Icon aria-label={arialLabel}>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
{...props}
>
<path d="M18 6 6 18" />
<path d="m6 6 12 12" />
</svg>
</Icon>
);
}
export function CalendarIcon({
'aria-label': arialLabel,
...props
}: React.JSX.IntrinsicElements['svg']) {
return (
<Icon aria-label={arialLabel}>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
{...props}
>
<path d="M8 2v4" />
<path d="M16 2v4" />
<rect width="18" height="18" x="3" y="4" rx="2" />
<path d="M3 10h18" />
</svg>
</Icon>
);
}
export function ChevronUpIcon({
'aria-label': arialLabel,
...props
}: React.JSX.IntrinsicElements['svg']) {
return (
<Icon aria-label={arialLabel}>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
{...props}
>
<path d="m18 15-6-6-6 6" />
</svg>
</Icon>
);
}
export function ChevronDownIcon({
'aria-label': arialLabel,
...props
}: React.JSX.IntrinsicElements['svg']) {
return (
<Icon aria-label={arialLabel}>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
{...props}
>
<path d="m6 9 6 6 6-6" />
</svg>
</Icon>
);
}
export function ChevronRightIcon({
'aria-label': arialLabel,
...props
}: React.JSX.IntrinsicElements['svg']) {
return (
<Icon aria-label={arialLabel}>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
{...props}
>
<path d="m9 18 6-6-6-6" />
</svg>
</Icon>
);
}
export function ChevronLeftIcon({
'aria-label': arialLabel,
...props
}: React.JSX.IntrinsicElements['svg']) {
return (
<Icon aria-label={arialLabel}>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
{...props}
>
<path d="m15 18-6-6 6-6" />
</svg>
</Icon>
);
}
export function SearchIcon({
'aria-label': arialLabel,
...props
}: React.JSX.IntrinsicElements['svg']) {
return (
<Icon aria-label={arialLabel}>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
{...props}
>
<path
fillRule="evenodd"
d="M9.965 11.026a5 5 0 1 1 1.06-1.06l2.755 2.754a.75.75 0 1 1-1.06 1.06l-2.755-2.754ZM10.5 7a3.5 3.5 0 1 1-7 0 3.5 3.5 0 0 1 7 0Z"
clipRule="evenodd"
></path>
</svg>
</Icon>
);
}
export function SpinnerIcon({
className,
'aria-label': arialLabel,
...props
}: React.JSX.IntrinsicElements['svg']) {
return (
<Icon aria-label={arialLabel}>
<svg
className={twMerge('animate-spin', className)}
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
{...props}
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
</Icon>
);
}
export function CopyIcon({
'aria-label': arialLabel,
...props
}: React.JSX.IntrinsicElements['svg']) {
return (
<Icon aria-label={arialLabel}>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
{...props}
>
<rect width="14" height="14" x="8" y="8" rx="2" ry="2" />
<path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" />
</svg>
</Icon>
);
}
export function AvailableIcon({
className,
'aria-label': arialLabel,
...props
}: React.JSX.IntrinsicElements['svg']) {
return (
<Icon aria-label={arialLabel}>
<svg
className={twMerge('text-emerald-600', className)}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 512 512"
fill="currentColor"
{...props}
>
<path d="M256 512A256 256 0 1 0 256 0a256 256 0 1 0 0 512z" />
</svg>
</Icon>
);
}
export function BusyIcon({
className,
'aria-label': arialLabel,
...props
}: React.JSX.IntrinsicElements['svg']) {
return (
<Icon aria-label={arialLabel}>
<svg
className={twMerge('text-red-600', className)}
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 512 512"
{...props}
>
<path d="M256 512A256 256 0 1 0 256 0a256 256 0 1 0 0 512zM175 175c9.4-9.4 24.6-9.4 33.9 0l47 47 47-47c9.4-9.4 24.6-9.4 33.9 0s9.4 24.6 0 33.9l-47 47 47 47c9.4 9.4 9.4 24.6 0 33.9s-24.6 9.4-33.9 0l-47-47-47 47c-9.4 9.4-24.6 9.4-33.9 0s-9.4-24.6 0-33.9l47-47-47-47c-9.4-9.4-9.4-24.6 0-33.9z" />
</svg>
</Icon>
);
}
export function AwayIcon({
className,
'aria-label': arialLabel,
...props
}: React.JSX.IntrinsicElements['svg']) {
return (
<Icon aria-label={arialLabel}>
<svg
className={twMerge('text-slate-400', className)}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 512 512"
fill="none"
stroke="currentColor"
strokeWidth="90"
strokeLinecap="round"
strokeLinejoin="round"
{...props}
>
<circle cx="256" cy="256" r="213" />
</svg>
</Icon>
);
}
export function DoNotDisturbIcon({
className,
'aria-label': arialLabel,
...props
}: React.JSX.IntrinsicElements['svg']) {
return (
<Icon aria-label={arialLabel}>
<svg
fill="currentColor"
className={twMerge('text-red-600', className)}
aria-hidden="true"
viewBox="0 0 10 10"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M5 10A5 5 0 1 0 5 0a5 5 0 0 0 0 10ZM3.5 4.5h3a.5.5 0 0 1 0 1h-3a.5.5 0 0 1 0-1Z"
fill="currentColor"
></path>
</svg>
</Icon>
);
}

View file

@ -0,0 +1,47 @@
const tokens = [
'oklch(0.552 0.016 285.938)',
'oklch(0.65 0.13 233.58)',
'oklch(0.42 0.12 253.27)',
'oklch(0.442 0.017 285.786)',
'oklch(0.37 0.013 285.805)',
'oklch(0.527 0.154 150.069)',
'oklch(0.45 0.11 151.32)',
'oklch(0.592 0.249 0.584)',
'oklch(0.459 0.187 3.815)',
'oklch(0.609 0.126 221.723)',
'oklch(0.52 0.105 223.128)',
'oklch(0.681 0.162 75.834)',
'oklch(0.476 0.114 61.907)',
'oklch(0.48 0.18 321.36)',
'oklch(0.401 0.17 325.612)',
'oklch(0.46 0.17 3.82)',
'oklch(0.41 0.159 10.272)',
];
export function getInitials(name: string) {
return name
.split(/\s/)
.map((part) => part.substring(0, 1))
.filter((v) => !!v)
.slice(0, 2)
.join('')
.toUpperCase();
}
function sumChars(str: string) {
let sum = 0;
for (let i = 0; i < str.length; i++) {
sum += str.charCodeAt(i);
}
return sum;
}
export function getInitialsToken(name: string, colorless: boolean) {
if (colorless) {
return tokens[0];
}
const i = sumChars(name) % tokens.length;
return tokens[i];
}

View file

@ -0,0 +1,27 @@
import { Keyboard as RACKeyboard } from 'react-aria-components';
import { twMerge } from 'tailwind-merge';
export type KeyboardProps = Omit<
React.JSX.IntrinsicElements['div'],
'children'
> & {
children: string;
outline?: boolean;
};
export function Kbd({ className, children, outline, ...props }: KeyboardProps) {
return (
<RACKeyboard
{...props}
data-ui="kbd"
className={twMerge(
'font-sans text-base/6 tracking-widest sm:text-sm/6',
outline &&
'rounded-sm bg-zinc-200 px-1 py-0.5 font-medium dark:bg-white/10',
className,
)}
>
{children}
</RACKeyboard>
);
}

View file

@ -0,0 +1,65 @@
import React from 'react';
import {
composeRenderProps,
Link as RACLink,
LinkProps as RACLinkProps,
} from 'react-aria-components';
import { twMerge } from 'tailwind-merge';
import { AsChildProps, Slot } from './slot';
import { TooltipTrigger } from './tooltip';
export type LinkProps = RACLinkProps & {
tooltip?: React.ReactNode;
};
export type LinkWithAsChild = AsChildProps<
RACLinkProps & {
tooltip?: React.ReactNode;
}
>;
const linkStyle = [
'relative inline-flex cursor-pointer items-center gap-1 rounded-sm outline-hidden hover:underline',
'text-base/6 sm:text-sm/6',
'[&.border]:hover:no-underline',
'[&>[data-ui=icon]:not([class*=size-])]:size-4',
'data-disabled:no-underline data-disabled:opacity-50 data-disabled:cursor-default',
].join(' ');
export const Link = React.forwardRef<HTMLAnchorElement, LinkWithAsChild>(
function Link(props, ref) {
if (props.asChild) {
return <Slot className={linkStyle}>{props.children}</Slot>;
}
const { asChild, tooltip, ...rest } = props;
const link = (
<RACLink
{...rest}
ref={ref}
className={composeRenderProps(
props.className,
(className, { isFocusVisible }) =>
twMerge(
linkStyle,
isFocusVisible &&
'outline outline-2 outline-offset-2 outline-ring',
className,
),
)}
/>
);
if (tooltip) {
return (
<TooltipTrigger>
{link}
{tooltip}
</TooltipTrigger>
);
}
return link;
},
);

View file

@ -0,0 +1,62 @@
import React from 'react';
import {
ListBox as RACListBox,
ListBoxItem as RACListBoxItem,
ListBoxProps as RACListBoxProps,
ListBoxItemProps,
composeRenderProps,
} from 'react-aria-components';
import { composeTailwindRenderProps } from './utils';
import { twMerge } from 'tailwind-merge';
export interface ListBoxProps<T>
extends Omit<RACListBoxProps<T>, 'layout' | 'orientation'> {}
export const ListBox = React.forwardRef(
<T extends object>(
props: ListBoxProps<T>,
ref: React.Ref<HTMLDivElement>,
) => {
return (
<RACListBox
{...props}
ref={ref}
className={composeTailwindRenderProps(props.className, [
'outline-hidden',
])}
/>
);
},
) as <T extends object>(
props: ListBoxProps<T> & { ref?: React.Ref<HTMLDivElement> },
) => React.JSX.Element;
export const ListBoxItem = React.forwardRef(
(props: ListBoxItemProps, ref: React.Ref<HTMLLIElement>) => {
const textValue =
props.textValue ||
(typeof props.children === 'string' ? props.children : undefined);
return (
<RACListBoxItem
{...props}
ref={ref}
textValue={textValue}
className={composeRenderProps(
props.className,
(className, { isFocusVisible, isDisabled }) =>
twMerge(
'group relative flex outline-0',
isDisabled && 'opacity-50',
isFocusVisible &&
'outline-ring outline outline-2 outline-offset-2',
className,
),
)}
/>
);
},
) as (
props: ListBoxItemProps & { ref?: React.Ref<HTMLLIElement> },
) => React.JSX.Element;

View file

@ -0,0 +1,263 @@
import {
Menu as RACMenu,
MenuItem as RACMenuItem,
MenuProps as RACMenuProps,
MenuItemProps as RACMenuItemProps,
composeRenderProps,
Separator,
Header,
MenuSectionProps as RACMenuSectionProps,
MenuSection as RACMenuSection,
Collection,
} from 'react-aria-components';
import { twMerge } from 'tailwind-merge';
import { Popover, PopoverProps } from './popover';
import { Button, ButtonProps } from './button';
import { composeTailwindRenderProps } from './utils';
import { Small } from './text';
import { CheckIcon, ChevronDownIcon, ChevronRightIcon } from './icons';
export { MenuTrigger, SubmenuTrigger } from 'react-aria-components';
type MenuButtonProps = ButtonProps & {
buttonArrow?: React.ReactNode;
};
export function MenuButton({
buttonArrow = <ChevronDownIcon className="ms-auto" />,
variant = 'outline',
children,
...props
}: MenuButtonProps) {
return (
<Button {...props} variant={variant}>
{(renderProps) => {
return (
<>
{typeof children === 'function' ? children(renderProps) : children}
{buttonArrow}
</>
);
}}
</Button>
);
}
export function MenuPopover({ className, ...props }: PopoverProps) {
return (
<Popover
{...props}
className={composeTailwindRenderProps(
className,
twMerge(
'max-w-72',
'min-w-[max(--spacing(36),var(--trigger-width))]',
'has-[[data-ui=content]_[data-ui=icon]]:min-w-[max(--spacing(48),var(--trigger-width))]',
'has-[[data-ui=content]_kbd]:min-w-[max(--spacing(11),var(--trigger-width))]',
),
)}
/>
);
}
type MenuProps<T> = RACMenuProps<T> & {
checkIconPlacement?: 'start' | 'end';
};
export function Menu<T extends object>({
checkIconPlacement = 'end',
...props
}: MenuProps<T>) {
return (
<RACMenu
{...props}
data-check-icon-placement={checkIconPlacement}
className={composeTailwindRenderProps(
props.className,
twMerge(
'max-h-[inherit] overflow-auto outline-hidden',
'flex flex-col',
'p-1 has-[header]:pt-0',
// Header, Menu item style when has selectable items
'[&_header]:px-2',
checkIconPlacement === 'start' &&
'[&:has(:is([role=menuitemradio],[role=menuitemcheckbox]))_:is(header,[role=menuitem])]:ps-7',
// Menu item content
'**:data-[ui=content]:flex-1',
'**:data-[ui=content]:grid',
'[&_[data-ui=content]:has([data-ui=label])]:grid-cols-[--spacing(4)_1fr_minmax(--spacing(12),max-content)]',
'**:data-[ui=content]:items-center',
'**:data-[ui=content]:gap-x-2',
// Icon
'[&_[data-ui=content]:not(:hover)>[data-ui=icon]:not([class*=text-])]:text-muted',
'[&_[data-ui=content][data-destructive]>[data-ui=icon]]:text-destructive',
'[&_[data-ui=content][data-destructive]:not(:hover)>[data-ui=icon]]:text-destructive/75',
'[&_[data-ui=content]>[data-ui=icon]:not([class*=size-])]:size-4',
'[&_[data-ui=content]>[data-ui=icon]:first-child]:col-start-1',
// Label
'**:data-[ui=label]:col-span-full',
'[&:has([data-ui=icon]+[data-ui=label])_[data-ui=label]]:col-start-2',
'[&:has([data-ui=kbd])_[data-ui=label]]:-col-end-2',
'[&:has([data-ui=icon]+[data-ui=label])_[data-ui=content]:not(:has(>[data-ui=label]))]:ps-6',
// Kbd
'**:data-[ui=kbd]:col-span-1',
'**:data-[ui=kbd]:row-start-1',
'**:data-[ui=kbd]:col-start-3',
'**:data-[ui=kbd]:justify-self-end',
'**:data-[ui=kbd]:text-xs/6',
'[&_:not([data-destructive])>[data-ui=kbd]:not([class*=bg-])]:text-muted/75',
'[&_[data-destructive]>[data-ui=kbd]]:text-destructive',
// Description
'**:data-[ui=description]:col-span-full',
'[&:has([data-ui=kbd])_[data-ui=description]]:-col-end-2',
'[&:has([data-ui=icon]+[data-ui=label])_[data-ui=description]]:col-start-2',
),
)}
/>
);
}
export function SubMenu<T extends object>(
props: MenuProps<T> & { 'aria-label': string },
) {
return <Menu {...props} />;
}
export function MenuSeparator({ className }: { className?: string }) {
return (
<Separator
className={twMerge(
'border-t-border/75 my-1 w-[calc(100%-(--spacing(4)))] self-center border-t',
className,
)}
/>
);
}
type MenuItemProps = RACMenuItemProps & {
destructive?: true;
};
export function MenuItem({ destructive, ...props }: MenuItemProps) {
const textValue =
props.textValue ||
(typeof props.children === 'string' ? props.children : undefined);
return (
<RACMenuItem
{...props}
textValue={textValue}
className={composeRenderProps(
props.className,
(className, { isFocused, isDisabled }) => {
return twMerge([
'group rounded-sm outline-hidden',
'flex items-center gap-x-1.5',
'px-2 py-2.5 sm:py-1.5',
'text-base/6 sm:text-sm/6',
isDisabled && 'opacity-50',
isFocused && 'bg-zinc-100 dark:bg-zinc-800',
destructive && 'text-destructive',
className,
]);
},
)}
>
{composeRenderProps(
props.children,
(children, { selectionMode, isSelected }) => (
<>
<CheckIcon
className={twMerge(
'flex h-[1lh] w-4 items-center self-start',
selectionMode == 'none'
? 'hidden'
: 'in-data-[check-icon-placement=end]:hidden',
isSelected ? 'visible' : 'invisible',
)}
/>
<div
data-ui="content"
data-destructive={destructive ? destructive : undefined}
>
{children}
</div>
<CheckIcon
className={twMerge(
'flex h-[1lh] w-4 items-center self-start',
selectionMode == 'none'
? 'hidden'
: 'in-data-[check-icon-placement=start]:hidden',
isSelected ? 'visible' : 'invisible',
)}
/>
{/* Submenu indicator */}
<ChevronRightIcon className="text-muted hidden size-4 group-data-has-submenu:inline-block" />
</>
),
)}
</RACMenuItem>
);
}
export function MenuItemLabel({
className,
...props
}: React.JSX.IntrinsicElements['span']) {
return (
<span
slot="label"
data-ui="label"
className={twMerge('truncate', className)}
{...props}
/>
);
}
export function MenuItemDescription({
className,
...props
}: React.JSX.IntrinsicElements['span']) {
return (
<Small
slot="description"
data-ui="description"
className={className}
{...props}
/>
);
}
export interface MenuSectionProps<T> extends RACMenuSectionProps<T> {
title?: string | React.ReactNode;
}
export function MenuSection<T extends object>({
className,
...props
}: MenuSectionProps<T>) {
return (
<RACMenuSection
{...props}
className={twMerge(
'not-first:mt-1.5',
'not-first:border-t',
'not-first:border-t-border/75',
className,
)}
>
<Header className="text-muted bg-background sticky inset-0 z-10 truncate pt-2 text-xs/6">
{props.title}
</Header>
<Collection items={props.items}>{props.children}</Collection>
</RACMenuSection>
);
}

View file

@ -0,0 +1,95 @@
import {
Meter as AriaMeter,
MeterProps as AriaMeterProps,
} from 'react-aria-components';
import { Label } from './field';
import { composeTailwindRenderProps } from './utils';
export interface MeterProps extends AriaMeterProps {
label?: string;
}
export function Meter({
label,
positive,
informative,
...props
}: MeterProps &
(
| {
positive?: true;
informative?: never;
}
| { positive?: never; informative?: true }
)) {
return (
<AriaMeter
{...props}
className={composeTailwindRenderProps(
props.className,
'flex flex-col gap-1',
)}
>
{({ percentage, valueText }) => (
<>
<div className="flex justify-between gap-2">
<Label>{label}</Label>
<span
className={`text-sm ${percentage >= 80 && !positive && !informative && 'text-destructive'}`}
>
{percentage >= 80 && !positive && (
<svg
aria-label="Alert"
className="inline-block size-5 align-text-bottom"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3" />
<path d="M12 9v4" />
<path d="M12 17h.01" />
</svg>
)}
{' ' + valueText}
</span>
</div>
<div className="relative h-2 w-64 rounded-full bg-gray-300 outline outline-1 -outline-offset-1 outline-transparent dark:bg-zinc-800">
<div
className={`absolute left-0 top-0 h-full rounded-full ${getColor(percentage, { positive, informative })}`}
style={{ width: percentage + '%' }}
/>
</div>
</>
)}
</AriaMeter>
);
}
function getColor(
percentage: number,
{ positive, informative }: { positive?: boolean; informative?: boolean },
) {
if (positive) {
return 'bg-success';
}
if (informative) {
return 'bg-blue-500';
}
if (percentage < 70) {
return 'bg-success';
}
if (percentage < 80) {
return 'bg-yellow-600';
}
return 'bg-destructive';
}

View file

@ -0,0 +1,173 @@
import React from 'react';
import {
ModalOverlay as RACModalOverlay,
ModalOverlayProps as RACModalOverlayProps,
Modal as RACModal,
} from 'react-aria-components';
import { composeTailwindRenderProps } from './utils';
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-lg',
'2xl': 'sm:max-w-2xl',
'3xl': 'sm:max-w-3xl',
'4xl': 'sm:max-w-4xl',
'5xl': 'sm:max-w-5xl',
fullWidth: 'w-full',
};
type ModalType =
| { drawer?: never; placement?: 'center' | 'top' }
| { drawer: true; placement?: 'left' | 'right' };
type ModalProps = Omit<RACModalOverlayProps, 'className'> & {
size?: keyof typeof sizes;
classNames?: {
modalOverlay?: RACModalOverlayProps['className'];
modal?: RACModalOverlayProps['className'];
};
} & ModalType;
export function Modal({ classNames, ...props }: ModalProps) {
const drawer = props.drawer;
const placement = props.drawer ? props.placement ?? 'left' : props.placement;
React.useEffect(() => {
document
.querySelector<HTMLElement>(':root')
?.style.setProperty(
'--scrollbar-width',
`${window.innerWidth - document.documentElement.clientWidth}px`,
);
}, []);
return (
<RACModalOverlay
{...props}
data-ui="modal-overlay"
className={composeTailwindRenderProps(classNames?.modalOverlay, [
'fixed top-0 left-0 isolate z-20',
'h-(--visual-viewport-height) w-full',
'bg-zinc-950/25 dark:bg-zinc-950/50',
'text-center',
'data-entering:animate-in',
'data-entering:fade-in',
'data-entering:duration-300',
'data-entering:ease-out',
'data-exiting:animate-out',
'data-exiting:fade-out',
'data-exiting:duration-200',
'data-exiting:ease-in',
drawer
? 'flex items-start p-2 [--visual-viewport-vertical-padding:16px] [&:has([data-placement=right])]:justify-end'
: [
'grid justify-items-center',
placement === 'center'
? 'grid-rows-[1fr_auto_1fr] p-4 [--visual-viewport-vertical-padding:32px]'
: [
// Default alert dialog style
'[&:has([role=alertdialog])]:grid-rows-[1fr_auto_1fr] sm:[&:has([role=alertdialog])]:grid-rows-[1fr_auto_3fr]',
'[&:has([role=alertdialog])]:p-4 [&:has([role=alertdialog])]:[--visual-viewport-vertical-padding:32px]',
// Default dialog style
placement === 'top'
? 'grid-rows-[1fr_auto_3fr] [&:has([role=dialog])]:p-4 sm:[&:has([role=dialog])]:[--visual-viewport-vertical-padding:32px]'
: [
'grid-rows-[1fr_auto] sm:grid-rows-[1fr_auto_3fr]',
'[&:has([role=dialog])]:pt-4 sm:[&:has([role=dialog])]:p-4',
'[&:has([role=dialog])]:[--visual-viewport-vertical-padding:16px]',
'sm:[&:has([role=dialog])]:[--visual-viewport-vertical-padding:32px]',
],
],
/**
* Style for stack dialogs
*/
// First dialog
'[&:has(~[data-ui=modal-overlay]:not([data-exiting]))>[data-ui=modal]>section]:opacity-75',
'[&:has(~[data-ui=modal-overlay]:not([data-exiting]))>[data-ui=modal]]:bg-zinc-100',
'dark:[&:has(~[data-ui=modal-overlay]:not([data-exiting]))>[data-ui=modal]]:bg-zinc-900',
'[&:has(~[data-ui=modal-overlay])>[data-ui=modal]]:transform-[scale,y]',
'[&:has(~[data-ui=modal-overlay])>[data-ui=modal]]:ease-in-out',
'[&:has(~[data-ui=modal-overlay])>[data-ui=modal]]:duration-200',
// When the nested dialog is not closing
'[&:has(~[data-ui=modal-overlay]:not([data-exiting]))>[data-ui=modal]]:scale-90',
// Remove nested dialog overlay background and fade in effect
'[&:has(~[data-ui=modal-overlay])~[data-ui=modal-overlay]]:bg-transparent',
'[&:has(~[data-ui=modal-overlay])~[data-ui=modal-overlay]]:fade-in-100',
// Make both dialogs close immediately
'[&:has(~[data-ui=modal-overlay])~[data-ui=modal-overlay][data-exiting]]:opacity-0',
'[&[data-exiting]:has(~[data-ui=modal-overlay])]:opacity-0',
],
])}
>
<RACModal
{...props}
data-ui="modal"
data-placement={placement}
className={composeTailwindRenderProps(classNames?.modal, [
'relative max-h-full w-full overflow-hidden',
'text-left align-middle',
'shadow-lg',
'bg-background',
'ring-1 ring-zinc-950/5 dark:ring-zinc-800',
props.size
? sizes[props.size]
: 'sm:has-[[role=alertdialog]]:max-w-md sm:has-[[role=dialog]]:max-w-lg',
'data-entering:animate-in',
'data-entering:ease-out',
'data-entering:duration-200',
'data-exiting:animate-out',
'data-exiting:ease-in',
'data-exiting:duration-200',
drawer
? [
'h-full',
'rounded-xl',
'data-[placement=left]:data-entering:slide-in-from-left',
'data-[placement=right]:data-entering:slide-in-from-right',
'data-[placement=left]:data-exiting:slide-out-to-left',
'data-[placement=right]:data-exiting:slide-out-to-right',
]
: [
'row-start-2',
'rounded-xl',
'data-entering:zoom-in-95',
'data-exiting:zoom-out-95',
// Handle layout shift when toggling scroll lock
props.size !== 'fullWidth' &&
'sm:data-exiting:-me-(--scrollbar-width)',
'sm:data-exiting:duration-0',
!placement && [
'has-[[role=dialog]]:rounded-t-xl',
'has-[[role=dialog]]:rounded-b-none',
'sm:has-[[role=dialog]]:rounded-xl',
'has-[[role=dialog]]:data-entering:zoom-in-100',
'has-[[role=dialog]]:data-entering:slide-in-from-bottom',
'sm:has-[[role=dialog]]:data-entering:zoom-in-95',
'sm:has-[[role=dialog]]:data-entering:slide-in-from-bottom-0',
'has-[[role=dialog]]:data-exiting:zoom-out-100',
'has-[[role=dialog]]:data-exiting:slide-out-to-bottom',
'sm:has-[[role=dialog]]:data-exiting:zoom-out-95',
'sm:has-[[role=dialog]]:data-exiting:slide-out-to-bottom-0',
],
],
])}
/>
</RACModalOverlay>
);
}

View file

@ -0,0 +1,366 @@
import React, { useState } from 'react';
import {
ComboBox,
ComboBoxProps as RACComboBoxProps,
Key,
ListBoxItemProps,
composeRenderProps,
GroupProps,
LabelContext,
Group,
} from 'react-aria-components';
import { useListData, ListData } from 'react-stately';
import { useFilter } from 'react-aria';
import {
DescriptionProvider,
LabeledGroup,
Input,
DescriptionContext,
} from './field';
import { Popover } from './popover';
import { ListBox, ListBoxItem } from './list-box';
import { Button } from './button';
import { twMerge } from 'tailwind-merge';
import { TagGroup, TagList } from './tag-group';
import { composeTailwindRenderProps, inputField } from './utils';
export interface MultiSelectProps<T extends object>
extends Omit<
RACComboBoxProps<T>,
| 'children'
| 'validate'
| 'allowsEmptyCollection'
| 'inputValue'
| 'selectedKey'
| 'inputValue'
| 'className'
| 'value'
| 'onSelectionChange'
| 'onInputChange'
> {
items: Array<T>;
selectedList: ListData<T>;
className?: string;
onItemAdd?: (key: Key) => void;
onItemRemove?: (key: Key) => void;
renderEmptyState: (inputValue: string) => React.ReactNode;
tag: (item: T) => React.ReactNode;
children: React.ReactNode | ((item: T) => React.ReactNode);
}
export function MultiSelectField({
children,
className,
...props
}: GroupProps & { children: React.ReactNode }) {
return (
<LabeledGroup {...props}>
<Group className={composeTailwindRenderProps(className, inputField)}>
<DescriptionProvider>{children}</DescriptionProvider>
</Group>
</LabeledGroup>
);
}
export function MultiSelect<
T extends {
id: Key;
textValue: string;
},
>({
children,
items,
selectedList,
onItemRemove,
onItemAdd,
className,
name,
renderEmptyState,
...props
}: MultiSelectProps<T>) {
const { contains } = useFilter({ sensitivity: 'base' });
const selectedKeys = selectedList.items.map((i) => i.id);
const filter = React.useCallback(
(item: T, filterText: string) => {
return (
!selectedKeys.includes(item.id) && contains(item.textValue, filterText)
);
},
[contains, selectedKeys],
);
const availableList = useListData({
initialItems: items,
filter,
});
const [fieldState, setFieldState] = useState<{
selectedKey: Key | null;
inputValue: string;
}>({
selectedKey: null,
inputValue: '',
});
const onRemove = React.useCallback(
(keys: Set<Key>) => {
const key = keys.values().next().value;
if (key) {
selectedList.remove(key);
setFieldState({
inputValue: '',
selectedKey: null,
});
onItemRemove?.(key);
}
},
[selectedList, onItemRemove],
);
const onSelectionChange = (id: Key | null) => {
if (!id) {
return;
}
const item = availableList.getItem(id);
if (!item) {
return;
}
if (!selectedKeys.includes(id)) {
selectedList.append(item);
setFieldState({
inputValue: '',
selectedKey: id,
});
onItemAdd?.(id);
}
availableList.setFilterText('');
};
const onInputChange = (value: string) => {
setFieldState((prevState) => ({
inputValue: value,
selectedKey: value === '' ? null : prevState.selectedKey,
}));
availableList.setFilterText(value);
};
const deleteLast = React.useCallback(() => {
if (selectedList.items.length == 0) {
return;
}
const lastKey = selectedList.items[selectedList.items.length - 1];
if (lastKey !== null) {
selectedList.remove(lastKey.id);
onItemRemove?.(lastKey.id);
}
setFieldState({
inputValue: '',
selectedKey: null,
});
}, [selectedList, onItemRemove]);
const onKeyDownCapture = React.useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Backspace' && fieldState.inputValue === '') {
deleteLast();
}
},
[deleteLast, fieldState.inputValue],
);
const tagGroupId = React.useId();
const triggerRef = React.useRef<HTMLDivElement | null>(null);
const [width, setWidth] = React.useState(0);
React.useEffect(() => {
const trigger = triggerRef.current;
if (!trigger) {
return;
}
const observer = new ResizeObserver((entries) => {
for (const entry of entries) {
setWidth(entry.target.clientWidth);
}
});
observer.observe(trigger);
return () => {
observer.unobserve(trigger);
};
}, [triggerRef]);
const triggerButtonRef = React.useRef<HTMLButtonElement | null>(null);
const labelContext = (React.useContext(LabelContext) ?? {}) as {
id?: string;
};
const descriptionContext = React.useContext(DescriptionContext);
return (
<>
<div
data-ui="control"
ref={triggerRef}
className={twMerge(
'relative',
'pe-4',
'flex min-h-9 w-[350px] flex-row flex-wrap items-center rounded-md',
'border has-[input[data-focused=true]]:border-ring',
'has-[input[data-invalid=true][data-focused=true]]:border-ring has-[input[data-invalid=true]]:border-destructive',
'has-[input[data-focused=true]]:ring-1 has-[input[data-focused=true]]:ring-ring',
className,
)}
>
{selectedList.items.length > 0 && (
<TagGroup
id={tagGroupId}
aria-labelledby={labelContext.id}
className="contents"
onRemove={onRemove}
>
<TagList
items={selectedList.items}
className={twMerge(
selectedList.items.length !== 0 && 'p-1',
'outline-hidden',
)}
>
{props.tag}
</TagList>
</TagGroup>
)}
<ComboBox
{...props}
allowsEmptyCollection
className={twMerge('group flex flex-1', className)}
items={availableList.items}
selectedKey={fieldState.selectedKey}
inputValue={fieldState.inputValue}
onSelectionChange={onSelectionChange}
onInputChange={onInputChange}
aria-labelledby={labelContext.id}
>
<div
className={[
'inline-flex flex-1 flex-wrap items-center gap-1 px-2',
selectedList.items.length > 0 && 'ps-0',
].join(' ')}
>
<Input
className="me-4 flex-1 border-0 px-0.5 py-0 outline-0 focus:ring-0"
onBlur={() => {
setFieldState({
inputValue: '',
selectedKey: null,
});
availableList.setFilterText('');
}}
aria-describedby={[
tagGroupId,
descriptionContext?.['aria-describedby'] ?? '',
].join(' ')}
onKeyDownCapture={onKeyDownCapture}
/>
<div className="sr-only" aria-hidden>
<Button variant="plain" ref={triggerButtonRef}>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="size-4"
>
<path d="m6 9 6 6 6-6" />
</svg>
</Button>
</div>
</div>
<Popover
style={{ width: `${width}px` }}
triggerRef={triggerRef}
className="max-w-none duration-0"
>
<ListBox<T>
renderEmptyState={() => renderEmptyState(fieldState.inputValue)}
selectionMode="multiple"
className="flex max-h-[inherit] flex-col gap-1.5 overflow-auto p-1.5 outline-hidden has-[header]:pt-0 sm:gap-0"
>
{children}
</ListBox>
</Popover>
</ComboBox>
<Button variant="plain" asChild>
<div
className="top-50 absolute end-0 me-1 size-6 rounded-sm p-0.5"
aria-hidden
>
{/* React Aria Button does not allow tabIndex */}
<button
type="button"
onClick={() => triggerButtonRef.current?.click()}
tabIndex={-1}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="size-4 text-muted group-hover:text-foreground"
>
<path d="m6 9 6 6 6-6" />
</svg>
</button>
</div>
</Button>
</div>
{name && (
<input hidden name={name} value={selectedKeys.join(',')} readOnly />
)}
</>
);
}
export function MultiSelectItem(props: ListBoxItemProps) {
return (
<ListBoxItem
{...props}
className={composeRenderProps(
props.className,
(className, { isFocused }) => {
return twMerge([
'rounded-md p-1.5 text-base/6 outline-0 focus-visible:outline-0 sm:text-sm/6',
isFocused && 'bg-zinc-100 dark:bg-zinc-700',
className,
]);
},
)}
>
{props.children}
</ListBoxItem>
);
}

View file

@ -0,0 +1,77 @@
import React from 'react';
import { useFocusRing } from 'react-aria';
import { twMerge } from 'tailwind-merge';
import { inputField } from './utils';
import { DescriptionContext, DescriptionProvider } from './field';
import { LabelContext } from 'react-aria-components';
export function NativeSelectField({
className,
...props
}: React.JSX.IntrinsicElements['div']) {
const labelId = React.useId();
return (
<LabelContext.Provider value={{ id: labelId, elementType: 'span' }}>
<DescriptionProvider>
<div
{...props}
data-ui="native-select-field"
className={twMerge(
'has-[select:disabled]:opacity-50',
inputField,
className,
)}
/>
</DescriptionProvider>
</LabelContext.Provider>
);
}
export function NativeSelect({
className,
...props
}: React.JSX.IntrinsicElements['select']) {
const { focusProps, isFocusVisible } = useFocusRing();
const labelContext = (React.useContext(LabelContext) ?? {}) as {
id?: string;
};
const descriptionContext = React.useContext(DescriptionContext);
return (
<div
data-ui="control"
className={twMerge(
'group relative isolate flex transition',
'after:pointer-events-none',
'after:absolute',
'after:border-muted',
'hover:after:border-foreground',
"after:content-['']",
'after:size-2 sm:after:size-1.5',
'after:border-r-[1.5px] after:border-b-[1.5px]',
'after:end-3 after:bottom-[55%] after:-translate-x-1/2 after:translate-y-1/2 after:rotate-45 rtl:after:translate-x-1.5',
)}
>
<select
{...focusProps}
aria-labelledby={labelContext.id}
aria-describedby={descriptionContext?.['aria-describedby']}
className={twMerge(
'w-full',
'appearance-none bg-transparent',
'ps-2.5 pe-8 sm:pe-7.5',
'py-[calc(--spacing(2.5)-1px)]',
'sm:py-[calc(--spacing(1.5)-1px)]',
'rounded-md border border-input outline-hidden',
'text-base/6 sm:text-sm/6',
'hover:bg-zinc-100 hover:dark:bg-zinc-800',
'hover:bg-zinc-100 dark:hover:bg-zinc-800',
isFocusVisible && 'border-ring ring-ring ring-1',
className,
)}
{...props}
/>
</div>
);
}

View file

@ -0,0 +1,68 @@
import { twMerge } from 'tailwind-merge';
type DotVariantProps = {
variant: 'dot';
inline?: boolean;
};
type NumericVariantProps = {
variant: 'numeric';
value: number;
inline?: boolean;
};
export type NotificationBadgeProps = (DotVariantProps | NumericVariantProps) &
React.JSX.IntrinsicElements['span'];
export function NotificationBadge({
className,
'aria-label': ariaLabel,
...props
}: NotificationBadgeProps) {
if (props.variant === 'dot') {
const { variant, inline, ...rest } = props;
return (
<>
<span
aria-hidden
className={twMerge(
inline ? '' : 'absolute top-1 right-1',
'flex size-2 rounded-full bg-red-600',
className,
)}
/>
{ariaLabel && (
<span role="status" className="sr-only" {...rest}>
{ariaLabel}
</span>
)}
</>
);
}
const { value, variant, inline, ...rest } = props;
return (
<>
<span
aria-hidden
className={twMerge([
inline ? '' : 'absolute -top-1.5 -right-1',
'flex h-4 items-center justify-center rounded-full bg-red-600 text-[0.65rem] text-white',
props.value > 0 ? (props.value > 9 ? 'w-5' : 'w-4') : 'hidden',
className,
])}
>
{Math.min(props.value, 9)}
{props.value > 9 ? <span className="pb-0.5">+</span> : null}
</span>
{ariaLabel && (
<span role="status" className="sr-only" {...rest}>
{ariaLabel}
</span>
)}
</>
);
}

View file

@ -0,0 +1,72 @@
import {
NumberField as RACNumberField,
NumberFieldProps as RACNumberFieldProps,
InputProps,
Group,
} from 'react-aria-components';
import { Input } from './field';
import { composeTailwindRenderProps, inputField } from './utils';
import { Button } from './button';
import { Separator } from './separator';
import { MinusIcon, PlusIcon } from './icons';
export interface NumberFieldProps extends RACNumberFieldProps {}
export function NumberField(props: NumberFieldProps) {
return (
<RACNumberField
{...props}
className={composeTailwindRenderProps(props.className, inputField)}
/>
);
}
export function NumberInput(props: InputProps) {
return (
<Group
data-ui="control"
className={[
'group isolate grid grid-cols-[auto_auto_1fr_auto_auto]',
'[&>div:has([role=separator])]:h-full',
'[&>div:has([role=separator])]:z-10',
'[&>div:has([role=separator])]:py-[1px]',
'[&:focus-within>div:has([role=separator])]:py-[2px]',
].join(' ')}
>
<Button
slot="decrement"
isIconOnly
variant="plain"
className="z-10 col-start-1 row-start-1 rounded-none hover:bg-transparent pressed:bg-transparent text-muted hover:text-foreground"
>
<MinusIcon />
</Button>
<div className="col-start-2 row-start-1">
<Separator orientation="vertical" className="h-full" />
</div>
<Input
{...props}
className={composeTailwindRenderProps(props.className, [
'z-0',
'col-span-full',
'row-start-1',
'px-[calc(theme(size.11)+10px)] sm:px-[calc(theme(size.9)+10px)]',
])}
/>
<div className="-col-end-2 row-start-1">
<Separator orientation="vertical" className="h-full" />
</div>
<Button
slot="increment"
className="-col-end-1 row-start-1 rounded-none text-muted hover:text-foreground hover:bg-transparent pressed:bg-transparent"
isIconOnly
variant="plain"
>
<PlusIcon/>
</Button>
</Group>
);
}

View file

@ -0,0 +1,109 @@
import { twMerge } from 'tailwind-merge';
import { Button } from './button';
import { Link } from './link';
import { LinkProps } from 'react-aria-components';
import { ChevronLeftIcon, ChevronRightIcon } from './icons';
export function Pagination({
className,
'aria-label': arialLabel = 'Page navigation',
...props
}: React.JSX.IntrinsicElements['nav']) {
return (
<nav
role="navigation"
aria-label={arialLabel}
className={twMerge(
'mx-auto flex w-full justify-center gap-x-2',
className,
)}
{...props}
/>
);
}
export function PaginationList({
className,
...props
}: React.JSX.IntrinsicElements['div']) {
return (
<div
{...props}
className={twMerge('flex hidden gap-x-1 sm:flex', className)}
/>
);
}
export function PaginationPrevious({
className,
label = 'Previous',
...props
}: LinkProps & { className?: string; label?: string }) {
return (
<Button asChild variant="plain">
<Link
{...props}
className={twMerge(
'px-3.5 outline-offset-0 hover:no-underline',
className,
)}
>
<ChevronLeftIcon />
{label}
</Link>
</Button>
);
}
export function PaginationNext({
className,
label = 'Next',
...props
}: LinkProps & { className?: string; label?: string }) {
return (
<Button asChild variant="plain">
<Link
{...props}
className={twMerge(
'px-3.5 outline-offset-1 hover:no-underline',
className,
)}
>
{label}
<ChevronRightIcon />
</Link>
</Button>
);
}
export function PaginationPage({
className,
current,
'aria-label': arialLabel,
...props
}: LinkProps & { className?: string; current?: boolean; children: string }) {
return (
<Button asChild {...(!current && { variant: 'plain' })}>
<Link
{...props}
aria-label={arialLabel ?? `Page ${props.children}`}
className={twMerge(
'min-w-9 outline-offset-1 hover:no-underline',
className,
)}
/>
</Button>
);
}
export function PaginationGap({
className,
...props
}: React.JSX.IntrinsicElements['span']) {
return (
<span {...props} aria-hidden className={twMerge('h-9 px-3.5', className)}>
&hellip;
</span>
);
}

View file

@ -0,0 +1,53 @@
import React from 'react';
import { InputProps, Group } from 'react-aria-components';
import { Input } from './field';
import { ToggleButton } from './button';
import { composeTailwindRenderProps } from './utils';
import { EyeIcon, EyeOffIcon } from './icons';
export function PasswordInput({className, ...props}: InputProps) {
const [isPasswordVisible, setIsPasswordVisible] = React.useState(false);
return (
<Group
data-ui="control"
className={[
'grid',
'grid-cols-[1fr_calc(theme(size.5)+20px)]',
'sm:grid-cols-[1fr_calc(theme(size.4)+20px)]',
].join(' ')}
>
<Input
{...props}
className={composeTailwindRenderProps(className, [
'peer',
'col-span-full',
'row-start-1',
'pe-10 sm:pe-9',
])}
type={isPasswordVisible ? 'text' : 'password'}
/>
<ToggleButton
isIconOnly
size="sm"
variant="plain"
aria-label="Show password"
isSelected={isPasswordVisible}
onChange={setIsPasswordVisible}
className={[
'group/toggle-password',
'focus-visible:-outline-offset-1',
'row-start-1',
'-col-end-1',
'place-self-center',
].join(' ')}
>
{isPasswordVisible ? (
<EyeOffIcon className="text-muted/75 group-hover/toggle-password:text-foreground" />
) : (
<EyeIcon className="text-muted/75 group-hover/toggle-password:text-foreground" />
)}
</ToggleButton>
</Group>
);
}

View file

@ -0,0 +1,47 @@
import {
Popover as RACPopover,
PopoverProps as RACPopoverProps,
useSlottedContext,
PopoverContext,
} from 'react-aria-components';
import React from 'react';
import { composeTailwindRenderProps } from './utils';
export interface PopoverProps extends Omit<RACPopoverProps, 'children'> {
children: React.ReactNode;
}
export function Popover(props: PopoverProps) {
const popoverContext = useSlottedContext(PopoverContext)!;
const isSubmenu = popoverContext?.trigger === 'SubmenuTrigger';
let offset = 8;
offset =
props.offset !== undefined
? props.offset
: isSubmenu
? offset - 14
: offset;
return (
<RACPopover
{...props}
offset={offset}
className={composeTailwindRenderProps(props.className, [
'bg-background',
'shadow-lg',
'rounded-md',
'ring-1',
'ring-zinc-950/10',
'dark:ring-zinc-800',
'data-entering:animate-in',
'data-entering:ease-out',
'data-entering:fade-in',
'data-exiting:animate-out',
'data-exiting:ease-in',
'data-exiting:fade-out',
'data-exiting:duration-50',
])}
/>
);
}

View file

@ -0,0 +1,37 @@
import {
ProgressBar as AriaProgressBar,
ProgressBarProps as AriaProgressBarProps,
} from 'react-aria-components';
import { Label } from './field';
import { composeTailwindRenderProps } from './utils';
export interface ProgressBarProps extends AriaProgressBarProps {
label?: string;
}
export function ProgressBar({ label, ...props }: ProgressBarProps) {
return (
<AriaProgressBar
{...props}
className={composeTailwindRenderProps(
props.className,
'flex flex-col gap-1',
)}
>
{({ percentage, valueText, isIndeterminate }) => (
<>
<div className="flex justify-between gap-2">
<Label>{label}</Label>
<span className="text-sm text-muted">{valueText}</span>
</div>
<div className="relative h-2 w-64 overflow-hidden rounded-full bg-gray-300 outline outline-1 -outline-offset-1 outline-transparent dark:bg-zinc-700">
<div
className={`absolute top-0 h-full rounded-full bg-accent ${isIndeterminate ? 'left-full duration-1000 ease-out animate-in slide-in-from-left-[20rem] repeat-infinite' : 'left-0'}`}
style={{ width: (isIndeterminate ? 40 : percentage) + '%' }}
/>
</div>
</>
)}
</AriaProgressBar>
);
}

View file

@ -0,0 +1,170 @@
import React from 'react';
import {
composeRenderProps,
Radio as RACRadio,
RadioGroup as RACRadioGroup,
RadioGroupProps as RACRadioGroupProps,
RadioProps as RACRadioProps,
RadioRenderProps,
} from 'react-aria-components';
import { twMerge } from 'tailwind-merge';
import { DescriptionContext, DescriptionProvider } from './field';
import { composeTailwindRenderProps, groupBox } from './utils';
export function RadioGroup(props: RACRadioGroupProps) {
return (
<RACRadioGroup
{...props}
className={composeTailwindRenderProps(props.className, groupBox)}
/>
);
}
export function Radios({
className,
...props
}: React.JSX.IntrinsicElements['div']) {
return (
<div
data-ui="box"
className={twMerge(
'flex',
'flex-col',
'group-aria-[orientation=horizontal]:flex-row',
'group-aria-[orientation=horizontal]:flex-wrap',
// When any radio item has description, apply all `font-medium` to all radio item labels
'has-data-[ui=description]:[&_label]:font-medium',
className,
)}
{...props}
/>
);
}
export function RadioField({
className,
...props
}: React.JSX.IntrinsicElements['div']) {
return (
<DescriptionProvider>
<div
{...props}
data-ui="field"
className={twMerge(
'group flex flex-col gap-y-1',
'has-data-[label-placement=start]:[&_label]:justify-between',
'has-[label[data-label-placement=start]]:[&_[data-ui=description]:not([class*=pe-])]:pe-16',
'has-[label[data-label-placement=end]]:[&_[data-ui=description]:not([class*=ps-])]:ps-7',
'has-[label[data-disabled]]:**:data-[ui=description]:opacity-50',
className,
)}
/>
</DescriptionProvider>
);
}
export interface RadioProps extends RACRadioProps {
labelPlacement?: 'start' | 'end';
radio?: React.ReactElement | ((props: RadioRenderProps) => React.ReactNode);
render?: never;
}
export interface CustomRenderRadioProps
extends Omit<RACRadioProps, 'children'> {
render:
| string
| React.ReactElement
| ((props: RadioRenderProps) => React.ReactNode);
radio?: never;
children?: never;
}
export function Radio(props: RadioProps | CustomRenderRadioProps) {
const descriptionContext = React.useContext(DescriptionContext);
if (props.render !== undefined) {
const { render, ...restProps } = props;
return (
<RACRadio
{...restProps}
aria-describedby={descriptionContext?.['aria-describedby']}
className={composeRenderProps(
props.className,
(className, { isDisabled, isFocusVisible }) =>
twMerge(
'group text-base/6 sm:text-sm/6',
isDisabled && 'opacity-50',
isFocusVisible &&
'outline-ring outline outline-2 outline-offset-2',
className,
),
)}
>
{render}
</RACRadio>
);
}
const { labelPlacement = 'end', radio, ...restProps } = props;
return (
<RACRadio
{...restProps}
aria-describedby={descriptionContext?.['aria-describedby']}
data-label-placement={labelPlacement}
className={composeRenderProps(
props.className,
(className, { isDisabled }) =>
twMerge(
'group flex items-center text-base/6 sm:text-sm/6',
'group-aria-[orientation=horizontal]:text-nowrap',
labelPlacement === 'start' && 'flex-row-reverse justify-between',
isDisabled && 'opacity-50',
className,
),
)}
>
{(renderProps) => {
return (
<>
<div
slot="radio"
className={twMerge(
'border-input grid shrink-0 place-content-center rounded-full border',
radio ? '' : 'size-4.5 sm:size-4',
labelPlacement === 'end' ? 'me-3' : 'ms-3',
renderProps.isReadOnly && 'opacity-50',
renderProps.isInvalid &&
'border-destructive dark:border-destructive',
renderProps.isSelected && 'border-accent bg-accent',
renderProps.isFocusVisible &&
'outline-ring outline outline-2 outline-offset-2',
)}
>
{radio ? (
typeof radio === 'function' ? (
radio(renderProps)
) : (
radio
)
) : (
<div
className={twMerge(
'rounded-full',
renderProps.isSelected &&
'size-2 bg-[lch(from_var(--color-accent)_calc((49.44_-_l)_*_infinity)_0_0)] sm:size-1.5',
)}
></div>
)}
</div>
{typeof props.children === 'function'
? props.children(renderProps)
: props.children}
</>
);
}}
</RACRadio>
);
}

View file

@ -0,0 +1,118 @@
import {
RangeCalendar as RACRangeCalendar,
RangeCalendarProps as RACRangeCalendarProps,
CalendarCell,
CalendarGrid,
CalendarGridBody,
DateValue,
Text,
composeRenderProps,
} from 'react-aria-components';
import { CalendarGridHeader, CalendarHeader } from './calendar';
import { twMerge } from 'tailwind-merge';
import { getLocalTimeZone, isToday } from '@internationalized/date';
export interface RangeCalendarProps<T extends DateValue>
extends Omit<RACRangeCalendarProps<T>, 'visibleDuration'> {
errorMessage?: string;
}
export function RangeCalendar<T extends DateValue>({
errorMessage,
...props
}: RangeCalendarProps<T>) {
return (
<RACRangeCalendar
{...props}
className={composeRenderProps(props.className, (className) => {
return twMerge('px-1.5 py-2.5', className);
})}
>
<CalendarHeader />
<CalendarGrid
className="border-separate border-spacing-y-1 px-3 sm:px-2"
weekdayStyle="short"
>
<CalendarGridHeader />
<CalendarGridBody>
{(date) => (
<CalendarCell
date={date}
className={composeRenderProps(
'',
(
className,
{ isSelected, isSelectionStart, isSelectionEnd, isInvalid },
) => {
return twMerge(
'group grid size-10 cursor-default place-items-center text-sm outline-hidden [td:first-child_&]:rounded-s-lg [td:last-child_&]:rounded-e-lg',
isToday(date, getLocalTimeZone()) && [
isSelected
? 'rounded-none'
: 'rounded-lg bg-zinc-100 dark:bg-zinc-800',
],
isSelected &&
'bg-accent/[0.07] dark:bg-accent/35 dark:text-white',
isSelected &&
isInvalid &&
'bg-destructive/15 text-destructive dark:bg-destructive/30',
isSelectionStart && 'rounded-s-lg',
isSelectionEnd && 'rounded-e-lg',
className,
);
},
)}
>
{({
formattedDate,
isSelected,
isInvalid,
isHovered,
isPressed,
isSelectionStart,
isSelectionEnd,
isFocusVisible,
isUnavailable,
isDisabled,
}) => (
<span
className={twMerge(
'relative flex size-[calc(--spacing(10)-1px)] items-center justify-center',
isHovered && [
'rounded-lg bg-zinc-100 dark:bg-zinc-700',
isPressed && 'bg-accent/90',
isSelected &&
'bg-accent dark:bg-accent text-[lch(from_var(--color-accent)_calc((49.44_-_l)_*_infinity)_0_0)]',
],
isDisabled && 'opacity-50',
isUnavailable &&
'text-destructive decoration-destructive line-through',
(isSelectionStart || isSelectionEnd) && [
'bg-accent rounded-lg text-sm text-[lch(from_var(--color-accent)_calc((49.44_-_l)_*_infinity)_0_0)]',
isHovered && 'bg-accent/90 dark:bg-accent/90',
isInvalid &&
'border-destructive bg-destructive text-white',
],
isFocusVisible && [
'outline-ring outline outline-2',
(isSelectionStart || isSelectionEnd) &&
'outline-offset-1',
'rounded-lg',
],
)}
>
{formattedDate}
</span>
)}
</CalendarCell>
)}
</CalendarGridBody>
</CalendarGrid>
{errorMessage && (
<Text slot="errorMessage" className="text-destructive text-sm">
{errorMessage}
</Text>
)}
</RACRangeCalendar>
);
}

View file

@ -0,0 +1,60 @@
import {
InputProps,
Group,
SearchField as RACSearchField,
SearchFieldProps as RACSearchFieldProps,
} from 'react-aria-components';
import { composeTailwindRenderProps, inputField } from './utils';
import { Button } from './button';
import { Input } from './field';
import { SearchIcon, SpinnerIcon, XIcon } from './icons';
export interface SearchFieldProps extends RACSearchFieldProps {}
export function SearchField(props: SearchFieldProps) {
return (
<RACSearchField
{...props}
className={composeTailwindRenderProps(props.className, inputField)}
></RACSearchField>
);
}
export function SearchInput({
isPending,
...props
}: InputProps & { isPending?: boolean }) {
return (
<Group
data-ui="control"
className={[
'isolate',
'grid',
'grid-cols-[calc(theme(size.5)+20px)_1fr_calc(theme(size.5)+20px)]',
'sm:grid-cols-[calc(theme(size.4)+20px)_1fr_calc(theme(size.4)+20px)]',
].join(' ')}
>
{isPending ? (
<SpinnerIcon className="z-10 col-start-1 row-start-1 size-5 place-self-center text-muted sm:size-4" />
) : (
<SearchIcon className="z-10 col-start-1 row-start-1 size-5 place-self-center text-muted sm:size-4" />
)}
<Input
{...props}
className={composeTailwindRenderProps(props.className, [
'[&::-webkit-search-cancel-button]:hidden',
'col-span-full row-start-1 pe-10 ps-10 sm:pe-9 sm:ps-8',
])}
/>
<Button
isIconOnly
variant="plain"
size="sm"
className="-col-end-1 row-start-1 place-self-center group-data-empty:invisible"
>
<XIcon aria-label="Clear" />
</Button>
</Group>
);
}

View file

@ -0,0 +1,288 @@
import React from 'react';
import {
Select as RACSelect,
SelectProps as RACSelectProps,
Header,
Button,
ListBoxItemProps,
SelectValue,
composeRenderProps,
Collection,
ListBoxSectionProps as RACListBoxSectionProps,
ListBoxSection as RACListBoxSection,
ListBoxItem as RACListBoxItem,
} from 'react-aria-components';
import { ListBoxProps, ListBox } from './list-box';
import { Popover, PopoverProps } from './popover';
import { composeTailwindRenderProps, inputField } from './utils';
import { twMerge } from 'tailwind-merge';
import { Small } from './text';
import { CheckIcon, ChevronDownIcon } from './icons';
import { Icon } from './icon';
export function Select<T extends object>(props: RACSelectProps<T>) {
return (
<RACSelect
{...props}
data-ui="select"
className={composeTailwindRenderProps(props.className, [
'w-full min-w-56',
inputField,
])}
/>
);
}
export function SelectButton(props: {
className?: string;
children?: React.ReactNode;
}) {
return (
<Button
data-ui="control"
className={composeRenderProps(
props.className,
(className, { isFocusVisible, isPressed, isHovered, isDisabled }) =>
twMerge(
'group border-input relative flex w-full cursor-default items-center gap-x-1 rounded-md border text-start outline-hidden transition',
'ps-3 pe-2.5',
'py-[calc(--spacing(2.5)-1px)]',
'sm:py-[calc(--spacing(1.5)-1px)]',
'text-base/6 sm:text-sm/6',
'group-data-invalid:border-destructive',
isDisabled && 'cursor-not-allowed opacity-50',
isHovered && ['bg-zinc-50 dark:bg-zinc-800'],
isPressed && ['bg-zinc-50 dark:bg-zinc-800'],
isHovered && isPressed && ['dark:bg-zinc-800'],
isFocusVisible &&
'border-ring ring-ring group-data-invalid:border-ring ring-1',
className,
),
)}
>
{!!props.children && (
<span className="flex items-center gap-x-2">{props.children}</span>
)}
<SelectValue
data-ui="select-value"
className={twMerge([
'data-placeholder:text-muted flex-1 truncate dark:data-placeholder:text-white',
// Selected Item style
'*:data-[ui=content]:flex',
'*:data-[ui=content]:items-center',
'*:data-[ui=content]:gap-x-2',
'[&>[data-ui=content]_[data-ui=description]]:sr-only',
'[&>[data-ui=content]:not(:hover)_[data-ui=icon]:not([class*=text-])]:text-muted',
'[&>[data-ui=content]_[data-ui=icon]:not([class*=size-])]:size-5',
'[&>[data-ui=content]_[role=img]]:size-6',
'sm:[&>[data-ui=content]_[data-ui=icon]:not([class*=size-])]:size-4',
'sm:[&>[data-ui=content]_[role=img]]:size-5',
])}
/>
<Icon className="group-[&:not(:hover)]:text-muted size-5 sm:size-4">
<ChevronDownIcon />
</Icon>
</Button>
);
}
export function SelectPopover({
className,
placement = 'bottom',
...props
}: PopoverProps) {
return (
<Popover
{...props}
className={composeTailwindRenderProps(className, ['w-(--trigger-width)'])}
placement={placement}
/>
);
}
export interface SelectListBoxProps<T>
extends ListBoxProps<T>,
React.RefAttributes<HTMLDivElement> {
checkIconPlacement?: 'start' | 'end';
}
export const SelectListBox = React.forwardRef(
<T extends object>(
{ checkIconPlacement = 'end', ...props }: SelectListBoxProps<T>,
ref: React.Ref<HTMLDivElement>, // Adjust ref type if ListBox renders something else
) => {
return (
<ListBox
{...props}
ref={ref} // Forward the ref to ListBox
data-check-icon-placement={checkIconPlacement}
className={composeTailwindRenderProps(props.className, [
'max-h-[inherit] overflow-auto',
'flex flex-col',
'p-1 has-[header]:pt-0',
// Listbox item
'**:data-[ui=content]:grid',
'**:data-[ui=content]:grid-cols-[minmax(--spacing(4),max-content)_1fr]',
'[&:has([data-ui=content]>[role=img])_[data-ui=content]]:grid-cols-[minmax(--spacing(5),max-content)_1fr]',
'[&:has([data-ui=content]>[role=img]+[data-ui=label]+[data-ui=description])_[data-ui=content]]:grid-cols-[minmax(--spacing(7),max-content)_1fr]',
'**:data-[ui=content]:items-center',
'**:data-[ui=content]:gap-x-2',
// Icon
'[&_[data-ui=content]>[data-ui=icon]:not([class*=size-])]:size-4',
'[&_[data-ui=content]:not(:hover)>[data-ui=icon]:not([class*=text-])]:text-muted',
// Label
'**:data-[ui=label]:col-span-full',
'[&:has(:is([data-ui=icon],[role=img])+[data-ui=label])_[data-ui=label]]:col-start-2',
'[&:has([data-ui=icon]+[data-ui=label])_[data-ui=content]:not(:has([data-ui=label]))]:ps-6',
'[&_[data-ui=label]:has(+[data-ui=description])]:leading-5',
// Description
'[&:has(:is([data-ui=icon],[role=img])+[data-ui=label])_[data-ui=description]]:col-start-2',
// Image
'[&_[role=img]]:size-5',
'[&:has([data-ui=description])_[role=img]]:size-7',
'[&_[role=img]]:self-start',
'[&_[role=img]]:mt-0.5',
'[&_[role=img]]:row-start-1',
'[&:has([data-ui=description])_[role=img]]:row-end-3',
])}
/>
);
},
) as <T extends object>(
props: SelectListBoxProps<T> & { ref?: React.Ref<HTMLDivElement> },
) => React.JSX.Element;
export interface SectionProps<T> extends RACListBoxSectionProps<T> {
title?: string | React.ReactNode;
}
export function SelectSection<T extends object>(props: SectionProps<T>) {
return (
<RACListBoxSection
className={twMerge(
'not-first:mt-1.5',
'border-border/75 not-first:border-t',
props.className,
)}
>
<Header
className={twMerge(
'text-muted bg-background sticky z-10 truncate ps-8 pt-2 text-xs/6',
'inset-0 rounded-sm',
'in-data-[check-icon-placement=end]:px-2',
)}
>
{props.title}
</Header>
<Collection items={props.items}>{props.children}</Collection>
</RACListBoxSection>
);
}
interface SelectListItemProps extends ListBoxItemProps {
destructive?: true;
checkIcon?: React.ReactNode;
}
export const SelectListItem = React.forwardRef(
(
{ destructive, checkIcon, ...props }: SelectListItemProps,
ref: React.Ref<HTMLLIElement>,
) => {
const textValue =
props.textValue ||
(typeof props.children === 'string' ? props.children : undefined);
return (
<RACListBoxItem
{...props}
ref={ref}
textValue={textValue}
className={composeRenderProps(
props.className,
(className, { isFocused, isDisabled, isHovered }) =>
twMerge(
'group flex cursor-default items-center gap-x-1.5 rounded-sm outline-hidden select-none',
'px-2 py-2.5 text-base/6 sm:py-1.5 sm:text-sm/6',
isDisabled && 'opacity-50',
(isFocused || isHovered) && 'bg-zinc-100 dark:bg-zinc-800',
destructive && 'text-destructive',
className,
),
)}
>
{composeRenderProps(props.children, (children, { isSelected }) => {
return (
<>
{checkIcon !== null && (
<span
className={twMerge(
'flex h-[1lh] items-center self-start',
'in-data-[check-icon-placement=end]:hidden',
'in-data-[ui=select-value]:hidden',
isSelected ? 'visible' : 'invisible',
)}
>
{checkIcon ?? <CheckIcon className="size-4" />}
</span>
)}
<div data-ui="content" className="w-full">
{children}
</div>
{checkIcon !== null && (
<span
className={twMerge(
'flex h-[1lh] items-center self-start',
'in-data-[ui=select-value]:hidden',
'in-data-[check-icon-placement=start]:hidden',
isSelected ? 'visible' : 'invisible',
)}
>
{checkIcon ?? <CheckIcon className="size-4" />}
</span>
)}
</>
);
})}
</RACListBoxItem>
);
},
) as (
props: SelectListItemProps & { ref?: React.Ref<HTMLLIElement> },
) => React.JSX.Element;
export function SelectListItemLabel({
className,
...props
}: React.JSX.IntrinsicElements['span']) {
return (
<span
{...props}
slot="label"
data-ui="label"
className={twMerge('mb-0 w-full truncate', className)}
/>
);
}
export function SelectListItemDescription({
className,
...props
}: React.JSX.IntrinsicElements['span']) {
return (
<Small
{...props}
slot="description"
data-ui="description"
className={className}
/>
);
}

View file

@ -0,0 +1,73 @@
import { useSeparator } from 'react-aria';
import { twMerge } from 'tailwind-merge';
import { SeparatorProps as RACSeparatorProps } from 'react-aria-components';
export type SeparatorProps = RACSeparatorProps & {
children?: React.ReactNode;
soft?: boolean;
} & React.JSX.IntrinsicElements['div'];
export function Separator({
orientation = 'horizontal',
className,
soft = false,
children,
...props
}: SeparatorProps) {
const { separatorProps } = useSeparator({ orientation });
return (
<div
{...separatorProps}
className={twMerge(
'text-sm/6',
'[&>svg:not([class*=size])]:size-5',
children
? [
soft
? 'before:border-border/75 after:border-border/75'
: 'before:border-border after:border-border',
orientation === 'vertical'
? [
'mx-4 flex flex-col items-center',
"before:content-['']",
'before:border-l',
'before:flex-1',
"after:content-['']",
'after:border-r',
'after:flex-1',
typeof children === 'string' && ['before:mb-4 after:mt-4'],
]
: [
'self-stretch',
'my-2 flex items-center',
"before:content-['']",
'before:border-t',
'before:flex-1',
"after:content-['']",
'after:border-t',
'after:flex-1',
typeof children === 'string' && ['before:me-4 after:ms-4'],
],
]
: [
soft ? 'border-border/75' : 'border-border',
orientation === 'vertical'
? [
'h-auto self-stretch border-l',
typeof children === 'string' && ['mx-1'],
]
: [
'h-px w-full self-stretch border-b',
typeof children === 'string' && ['my-1'],
],
],
className,
)}
{...props}
>
{children}
</div>
);
}

View file

@ -0,0 +1,13 @@
import { twMerge } from 'tailwind-merge';
export function Skeleton({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={twMerge('animate-pulse rounded-md bg-zinc-200 dark:bg-zinc-700', className)}
{...props}
/>
);
}

View file

@ -0,0 +1,112 @@
import {
Slider as RACSlider,
SliderProps as RACSliderProps,
SliderThumb,
SliderTrack as RACSliderTrack,
SliderRenderProps,
composeRenderProps,
} from 'react-aria-components';
import { composeTailwindRenderProps } from './utils';
import { twMerge } from 'tailwind-merge';
export { SliderOutput } from 'react-aria-components';
export interface SliderProps<T> extends RACSliderProps<T> {
label?: string;
thumbLabels?: string[];
}
export function Slider<T extends number | number[]>(props: SliderProps<T>) {
return (
<RACSlider
{...props}
className={composeTailwindRenderProps(
props.className,
'flex flex-col gap-2 data-[orientation=horizontal]:min-w-64 data-[orientation=vertical]:items-center',
)}
/>
);
}
const trackStyle = [
'absolute rounded-full',
'group-data-[orientation=horizontal]:h-1.5',
'group-data-[orientation=horizontal]:w-full',
'group-data-[orientation=vertical]:top-1/2',
'group-data-[orientation=vertical]:left-1/2',
'group-data-[orientation=vertical]:h-full',
'group-data-[orientation=vertical]:w-[6px]',
'group-data-disabled:opacity-50',
];
export function SliderTack({ thumbLabels }: { thumbLabels?: string[] }) {
return (
<RACSliderTrack className="group relative flex w-full items-center data-[orientation=horizontal]:h-7 data-[orientation=vertical]:h-44 data-[orientation=vertical]:w-7">
{({ state, orientation }) => {
return (
<>
<div
className={twMerge(
'bg-zinc-200 group-data-[orientation=vertical]:-translate-x-1/2 group-data-[orientation=vertical]:-translate-y-1/2 dark:bg-zinc-600',
trackStyle,
)}
/>
<div
className={twMerge('bg-accent', trackStyle)}
style={getTrackHighlightStyle(state, orientation)}
/>
{state.values.map((_, i) => (
<SliderThumb
key={i}
index={i}
aria-label={thumbLabels?.[i]}
className={composeRenderProps(
'',
(className, { isFocusVisible, isDragging, isDisabled }) =>
twMerge(
'border-accent size-4 rounded-full border border-2 bg-[lch(from_var(--color-accent)_calc((49.44_-_l)_*_infinity)_0_0)] shadow-xl dark:border-3',
'group-data-[orientation=horizontal]:top-1/2 group-data-[orientation=vertical]:left-1/2',
isDragging && ['border-4 dark:border-4'],
isDisabled && 'cursor-not-allowed opacity-50',
isFocusVisible && [
'outline',
'outline-2',
'outline-ring',
'outline-offset-2',
],
className,
),
)}
/>
))}
</>
);
}}
</RACSliderTrack>
);
}
function getTrackHighlightStyle(
state: SliderRenderProps['state'],
orientation: SliderRenderProps['orientation'],
) {
const hasTwoThumbs = state.values.length == 2;
const highlightPercentage = hasTwoThumbs
? (state.getThumbPercent(1) - state.getThumbPercent(0)) * 100 + '%'
: state.getThumbPercent(0) * 100 + '%';
const highlightStartPosition = hasTwoThumbs
? state.getThumbPercent(0) * 100 + '%'
: '0';
return orientation === 'horizontal'
? {
width: highlightPercentage,
left: highlightStartPosition,
}
: {
height: highlightPercentage,
bottom: highlightStartPosition,
top: 'auto',
transform: 'translate(-50%,0px)',
};
}

View file

@ -0,0 +1,44 @@
// https://www.jacobparis.com/content/react-as-child
import React from 'react';
import { twMerge } from 'tailwind-merge';
export type AsChildProps<DefaultElementProps> =
| ({ asChild?: false } & DefaultElementProps)
| { asChild: true; children: React.ReactNode };
type cloneElement = React.ReactElement<{
style?: React.CSSProperties;
className?: string;
}>;
export function Slot({
children,
...props
}: React.HTMLAttributes<HTMLElement> & {
children?: React.ReactNode;
}) {
if ('asChild' in props) {
delete props.asChild;
}
if (React.isValidElement(children) && typeof children.props === 'object') {
return React.cloneElement(children as cloneElement, {
...props,
...children.props,
style: {
...props.style,
...(children as cloneElement).props?.style,
},
className: twMerge(
props.className,
(children as cloneElement).props?.className,
),
});
}
if (React.Children.count(children) > 1) {
React.Children.only(null);
}
return null;
}

View file

@ -0,0 +1,155 @@
import React from 'react';
import { twMerge } from 'tailwind-merge';
import {
composeRenderProps,
Group,
GroupProps,
Switch as RACSwitch,
SwitchProps as RACSwitchProps,
SwitchRenderProps,
} from 'react-aria-components';
import { groupBox, composeTailwindRenderProps } from './utils';
import { DescriptionProvider, DescriptionContext, LabeledGroup } from './field';
export function SwitchGroup(props: GroupProps) {
return (
<LabeledGroup>
<Group
{...props}
className={composeTailwindRenderProps(props.className, groupBox)}
></Group>
</LabeledGroup>
);
}
export function Switches({
className,
...props
}: React.JSX.IntrinsicElements['div']) {
return (
<div
{...props}
data-ui="box"
className={twMerge(
'flex flex-col',
// When any switch item has description, apply all `font-medium` to all switch item labels
'has-data-[ui=description]:[&_label]:font-medium',
className,
)}
/>
);
}
export function SwitchField({
className,
...props
}: React.JSX.IntrinsicElements['div']) {
return (
<DescriptionProvider>
<div
{...props}
data-ui="field"
className={twMerge(
'group flex flex-col gap-y-1',
'has-[label[data-label-placement=start]]:[&_[data-ui=description]:not([class*=pe-])]:pe-[calc(theme(width.8)+16px)]',
'has-[label[data-label-placement=end]]:[&_[data-ui=description]:not([class*=ps-])]:ps-[calc(theme(width.8)+12px)]',
'has-data-[ui=description]:[&_label]:font-medium',
'has-[label[data-disabled]]:**:data-[ui=description]:opacity-50',
className,
)}
/>
</DescriptionProvider>
);
}
interface SwitchProps extends RACSwitchProps {
labelPlacement?: 'start' | 'end';
size?: 'lg';
render?: never;
}
export interface CustomRenderSwitchProps
extends Omit<RACSwitchProps, 'children'> {
render: React.ReactElement | ((props: SwitchRenderProps) => React.ReactNode);
children?: never;
size?: never;
labelPlacement?: never;
}
export function Switch(props: SwitchProps | CustomRenderSwitchProps) {
const descriptionContext = React.useContext(DescriptionContext);
if (props.render) {
const { render, ...restProps } = props;
return (
<RACSwitch
{...restProps}
aria-describedby={descriptionContext?.['aria-describedby']}
className={composeRenderProps(
props.className,
(className, { isDisabled }) =>
twMerge(
'group text-base/6 sm:text-sm/6',
isDisabled && 'opacity-50',
className,
),
)}
>
{render}
</RACSwitch>
);
}
const { labelPlacement = 'end', size, children, ...restProps } = props;
return (
<RACSwitch
{...restProps}
aria-describedby={descriptionContext?.['aria-describedby']}
data-label-placement={labelPlacement}
className={composeRenderProps(
props.className,
(className, { isDisabled }) =>
twMerge(
'group flex items-center text-base/6 sm:text-sm/6',
labelPlacement === 'start' && 'flex-row-reverse justify-between',
isDisabled && 'opacity-50',
className,
),
)}
>
{(renderProps) => (
<>
<div
className={twMerge(
'dark:border-input flex h-6 w-11 shrink-0 cursor-default items-center rounded-full border border-zinc-200 bg-zinc-200 p-px dark:bg-transparent',
size !== 'lg' && 'sm:h-5 sm:w-8',
labelPlacement === 'end' ? 'me-3' : 'ms-3',
renderProps.isReadOnly && 'opacity-50',
renderProps.isSelected &&
'border-accent bg-accent dark:bg-accent dark:border-accent',
renderProps.isDisabled && 'bg-gray-200 dark:bg-zinc-700',
renderProps.isFocusVisible &&
'outline-ring outline outline-2 outline-offset-2',
)}
>
<span
data-ui="handle"
className={twMerge(
'size-5',
size !== 'lg' && 'sm:size-4',
'rounded-full bg-white shadow transition-all ease-in-out',
renderProps.isSelected && [
'translate-x-5 bg-[lch(from_var(--color-accent)_calc((49.44_-_l)_*_infinity)_0_0)] rtl:-translate-x-5',
size !== 'lg' && 'sm:translate-x-3 sm:rtl:-translate-x-3',
],
)}
/>
</div>
{typeof children === 'function' ? children(renderProps) : children}
</>
)}
</RACSwitch>
);
}

View file

@ -0,0 +1,187 @@
import {
Cell as AriaCell,
Column as AriaColumn,
Row as AriaRow,
Table as AriaTable,
TableHeader as AriaTableHeader,
Button,
CellProps,
Collection,
ColumnProps,
ColumnResizer,
Group,
ResizableTableContainer,
RowProps,
TableHeaderProps,
TableProps,
composeRenderProps,
useTableOptions,
} from 'react-aria-components';
import { twMerge } from 'tailwind-merge';
import { Checkbox } from './checkbox';
import { composeTailwindRenderProps } from './utils';
import { ChevronUpIcon } from './icons';
export function Table(props: TableProps) {
return (
<ResizableTableContainer className="relative max-h-[280px] w-[550px] scroll-pt-[2.281rem] overflow-auto rounded-md border">
<AriaTable {...props} className="border-separate border-spacing-0" />
</ResizableTableContainer>
);
}
export function Column(props: ColumnProps) {
return (
<AriaColumn
{...props}
className={composeTailwindRenderProps(
props.className,
'cursor-default border-b text-start text-sm font-semibold focus-within:z-20 [&:hover]:z-20',
)}
>
{composeRenderProps(
props.children,
(children, { allowsSorting, sortDirection }) => (
<div className="flex items-center">
<Group
role="presentation"
tabIndex={-1}
className={composeRenderProps(
'',
(className, { isFocusVisible }) =>
twMerge(
isFocusVisible
? 'outline-ring rounded-sm outline outline-2 outline-offset-2'
: 'outline-hidden',
'flex h-5 flex-1 items-center gap-1 overflow-hidden px-2',
className,
),
)}
>
<span className="truncate">{children}</span>
{allowsSorting && (
<span
className={`flex size-4 items-center justify-center transition ${
sortDirection === 'descending' ? 'rotate-180' : ''
}`}
>
{sortDirection && (
<ChevronUpIcon className="size-4 text-muted" />
)}
</span>
)}
</Group>
{!props.width && (
<ColumnResizer
className={composeRenderProps(
'',
(className, { isFocusVisible, isResizing }) =>
twMerge(
'box-content h-5 w-[1.5px] translate-x-[8px] cursor-col-resize rounded-sm bg-border bg-clip-content px-[8px] py-1',
isResizing &&
'resizing:w-[2px] resizing:bg-accent resizing:pl-[7px]',
isFocusVisible
? 'outline-ring rounded-sm outline outline-2 -outline-offset-2'
: 'outline-hidden',
className,
),
)}
/>
)}
</div>
),
)}
</AriaColumn>
);
}
export function TableHeader<T extends object>(props: TableHeaderProps<T>) {
const { selectionBehavior, selectionMode, allowsDragging } =
useTableOptions();
return (
<AriaTableHeader
{...props}
className={composeTailwindRenderProps(props.className, [
'sticky top-0 z-10 rounded-t-md backdrop-blur-md',
"after:content-['']",
'after:flex-1',
])}
>
{/* Add extra columns for drag and drop and selection. */}
{allowsDragging && <Column />}
{selectionBehavior === 'toggle' && (
<AriaColumn
width={36}
minWidth={36}
className="cursor-default border-b p-2 text-start text-sm font-semibold"
>
{selectionMode === 'multiple' && <Checkbox slot="selection" />}
</AriaColumn>
)}
<Collection items={props.columns}>{props.children}</Collection>
</AriaTableHeader>
);
}
export function Row<T extends object>({
id,
columns,
children,
...props
}: RowProps<T>) {
const { selectionBehavior, allowsDragging } = useTableOptions();
return (
<AriaRow
id={id}
{...props}
className={composeRenderProps(
props.className,
(className, { isFocusVisible, isSelected, isHovered, isDisabled }) =>
twMerge(
'group/row relative cursor-default select-none text-sm',
isDisabled && 'text-muted',
isHovered && 'bg-zinc-100 dark:bg-zinc-700',
isSelected && 'bg-accent/5 dark:bg-accent/35',
isHovered && isSelected && 'bg-zinc-100 dark:selected:bg-zinc-700',
isFocusVisible
? 'outline-ring rounded-sm outline outline-2 -outline-offset-2'
: 'outline-hidden',
className,
),
)}
>
{allowsDragging && (
<Cell>
<Button slot="drag"></Button>
</Cell>
)}
{selectionBehavior === 'toggle' && (
<Cell>
<Checkbox slot="selection" />
</Cell>
)}
<Collection items={columns}>{children}</Collection>
</AriaRow>
);
}
export function Cell(props: CellProps) {
return (
<AriaCell
{...props}
className={composeRenderProps(
props.className,
(className, { isFocusVisible }) =>
twMerge(
'truncate border-b p-2 group-last/row:border-b-0',
isFocusVisible
? 'outline-ring rounded-sm outline outline-2 -outline-offset-2'
: 'outline-hidden',
className,
),
)}
/>
);
}

View file

@ -0,0 +1,203 @@
import React from 'react';
import {
Tab as RACTab,
TabList as RACTabList,
TabPanel as RACTabPanel,
Tabs as RACTabs,
TabListProps,
TabPanelProps,
TabProps,
TabsProps as RACTabProps,
composeRenderProps,
TabRenderProps,
} from 'react-aria-components';
import { twMerge } from 'tailwind-merge';
type Orientation = 'vertical' | 'horizontal';
type Variant = 'underline' | 'pills' | 'segment';
const TabsContext = React.createContext<{
variant: Variant;
orientation: Orientation;
}>({
variant: 'underline',
orientation: 'horizontal',
});
export type TabsProps = RACTabProps &
(
| { variant?: 'underline' | 'pills' }
| { variant: 'segment'; orientation?: never }
);
export function Tabs({
variant = 'underline',
orientation = 'horizontal',
keyboardActivation = 'manual',
...props
}: TabsProps) {
return (
<TabsContext.Provider value={{ variant, orientation }}>
<RACTabs
{...props}
keyboardActivation={keyboardActivation}
orientation={orientation}
className={composeRenderProps(props.className, (className) =>
twMerge([
'group flex',
orientation === 'horizontal' ? 'flex-col' : 'flex-col sm:flex-row',
className,
]),
)}
/>
</TabsContext.Provider>
);
}
const tabList = {
base: {
horizontal: 'whitespace-nowrap',
vertical: 'flex-col flex-1 sm:flex-initial',
},
underline: {
horizontal: 'w-full space-x-4 border-b',
vertical: 'space-y-3.5 self-start border-l',
},
pills: {
horizontal: 'space-x-4',
vertical: 'space-y-2',
},
segment: {
horizontal: 'p-0.5 rounded-lg bg-zinc-200/75 dark:bg-zinc-800 shadow-xs',
vertical: '',
},
};
export function TabList<T extends object>(props: TabListProps<T>) {
const { variant, orientation } = React.useContext(TabsContext);
return (
<div className="flex overflow-x-auto pb-px pl-px">
<RACTabList
{...props}
className={composeRenderProps(props.className, (className) =>
twMerge([
'flex',
'text-base/6 sm:text-sm/6',
tabList.base[orientation],
tabList[variant][orientation],
className,
]),
)}
/>
</div>
);
}
const tabPanel = {
underline: {
horizontal: ['py-4'],
vertical: ['px-4'],
},
pills: {
horizontal: ['px-5 py-4'],
vertical: ['p-4 sm:pl-8 sm:py-0'],
},
segment: {
horizontal: ['px-3 py-4'],
vertical: [],
},
};
export function TabPanel(props: TabPanelProps) {
const { variant, orientation } = React.useContext(TabsContext);
return (
<RACTabPanel
{...props}
className={composeRenderProps(props.className, (className) =>
twMerge([
'flex-1 outline-hidden',
tabPanel[variant][orientation],
className,
]),
)}
/>
);
}
const tab = ({
isSelected,
isDisabled,
isHovered,
isFocusVisible,
variant,
orientation,
}: {
variant: Variant;
orientation: Orientation;
} & TabRenderProps) => {
const style = {
base: [
'outline-hidden relative flex items-center gap-x-3 rounded-md font-medium',
'[&>[data-ui=icon]:not([class*=size-])]:size-5',
isDisabled && 'opacity-50',
isSelected || isHovered ? 'text-foreground' : 'text-muted',
isFocusVisible && 'ring-2 ring-inset ring-ring',
],
underline: {
base: 'before:absolute before:bg-accent',
horizontal: [
'p-2 before:bottom-[-1.5px] before:w-full before:inset-x-0',
isSelected && 'before:h-[2px]',
],
vertical: [
'px-4',
'before:inset-y-0',
isSelected && 'before:bg-accent before:left-[-1.5px] before:w-[2px]',
],
},
pills: {
base: [
'flex items-center px-3 py-2',
isSelected && 'bg-zinc-100 dark:bg-zinc-600/45',
],
horizontal: '',
vertical: '',
},
segment: {
base: [
'flex-1 justify-center px-6 py-1 [&>[data-ui=icon]:not([class*=size-])]:size-4',
isSelected &&
'bg-background dark:bg-zinc-500 text-foreground shadow-2xs rounded-md',
],
horizontal: '',
vertical: '',
},
};
return [style.base, style[variant].base, style[variant][orientation]];
};
export function Tab(props: TabProps) {
const { variant, orientation } = React.useContext(TabsContext);
return (
<RACTab
{...props}
className={composeRenderProps(
props.className,
(className, renderProps) => {
return twMerge(
tab({
variant,
orientation,
...renderProps,
}),
className,
);
},
)}
/>
);
}

View file

@ -0,0 +1,112 @@
import React from 'react';
import {
Tag as AriaTag,
TagGroup as AriaTagGroup,
TagGroupProps as AriaTagGroupProps,
TagProps as AriaTagProps,
Button,
composeRenderProps,
TagList as RACTagList,
TagListProps,
} from 'react-aria-components';
import { twMerge } from 'tailwind-merge';
import { composeTailwindRenderProps } from './utils';
import { XIcon } from './icons';
const colors = {
default: '[--tag:var(--color-accent)]',
success: '[--tag:var(--color-success)]',
warning: '[--tag:var(--color-warning)]',
destructive: '[--tag:var(--destructive)]',
} as const;
type Color = keyof typeof colors;
const ColorContext = React.createContext<Color>('default');
export interface TagGroupProps extends AriaTagGroupProps {
color?: Color;
}
export interface TagProps extends AriaTagProps {
color?: Color;
}
export function TagGroup({ children, ...props }: TagGroupProps) {
return (
<AriaTagGroup
{...props}
className={twMerge('flex flex-col gap-1', props.className)}
>
<ColorContext.Provider value={props.color || 'default'}>
{children}
</ColorContext.Provider>
</AriaTagGroup>
);
}
export function TagList<T extends object>(props: TagListProps<T>) {
return (
<RACTagList
{...props}
className={composeTailwindRenderProps(
props.className,
'flex flex-wrap gap-1',
)}
/>
);
}
export function Tag({ children, color, ...props }: TagProps) {
const textValue = typeof children === 'string' ? children : undefined;
const groupColor = React.useContext(ColorContext);
const tagColor = color ?? groupColor ?? 'default';
return (
<AriaTag
textValue={textValue}
{...props}
className={composeRenderProps(
props.className,
(className, { isFocusVisible, isDisabled, isSelected }) =>
twMerge(
'flex max-w-fit cursor-default items-center gap-x-1 rounded-md px-2 py-0.5 text-xs/5 font-medium outline-0 transition data-selection-mode:cursor-pointer',
colors[tagColor],
isSelected
? 'bg-(--tag) text-[lch(from_var(--tag)_calc((49.44_-_l)_*_infinity)_0_0)]'
: 'bg-(--tag)/15 text-(--tag)',
isFocusVisible && 'outline-ring outline outline-2 outline-offset-1',
isDisabled && 'opacity-50',
className,
),
)}
>
{(renderProps) => {
return (
<>
{typeof children === 'function' ? children(renderProps) : children}
{renderProps.allowsRemoving && (
<Button
slot="remove"
className={composeRenderProps(
'',
(className, { isPressed, isHovered, isFocusVisible }) =>
twMerge(
'flex cursor-default items-center justify-center rounded-full p-0.5 outline-0 transition-[background-color]',
isHovered && 'pressed: bg-black/10 dark:bg-white/10',
isPressed && 'bg-black/20 dark:bg-white/20',
isFocusVisible &&
'outline-ring outline outline-2 outline-offset-2',
className,
),
)}
>
<XIcon className="size-3"></XIcon>
</Button>
)}
</>
);
}}
</AriaTag>
);
}

View file

@ -0,0 +1,176 @@
import React from 'react';
import { LabelContext, TextFieldProps, type Key } from 'react-aria-components';
import { Tag, TagGroup, TagList } from './tag-group';
import { ListData } from 'react-stately';
import { Input, TextField } from './field';
import { twMerge } from 'tailwind-merge';
interface TagItem {
id: number;
name: string;
}
interface ContextType {
list: ListData<TagItem>;
onTagAdd?: (tag: TagItem) => void;
onTagRemove?: (tag: TagItem) => void;
}
const TagInputContext = React.createContext<ContextType | null>(null);
function useTagInputContext() {
const context = React.useContext(TagInputContext);
if (!context) {
throw new Error('<TagInputContext.Provider> is required');
}
return context;
}
export interface TagInputProps
extends Omit<ContextType, 'tagGroupId'>,
TextFieldProps {
children: React.ReactNode;
className?: string;
}
export function TagsInputField({
list,
name,
onTagRemove,
onTagAdd,
...props
}: TagInputProps) {
return (
<TagInputContext.Provider value={{ list, onTagAdd, onTagRemove }}>
<TextField {...props} />
{name && (
<input
name={name}
hidden
readOnly
value={list.items.map(({ name }) => name).join(',')}
/>
)}
</TagInputContext.Provider>
);
}
export function TagsInput({
className,
}: {
className?: string;
children?: React.ReactNode;
}) {
const [inputValue, setInputValue] = React.useState('');
const { list, onTagAdd, onTagRemove } = useTagInputContext();
const deleteLast = React.useCallback(() => {
if (list.items.length == 0) {
return;
}
const lastKey = list.items[list.items.length - 1];
if (lastKey !== null) {
list.remove(lastKey.id);
const item = list.getItem(lastKey.id);
if (item) {
onTagRemove?.(item);
}
}
}, [list, onTagRemove]);
function handleKeyDown(e: React.KeyboardEvent) {
if (e.key === 'Enter' || e.key === ',' || e.key === ';') {
e.preventDefault();
addTag();
}
if (e.key === 'Backspace' && inputValue === '') {
deleteLast();
}
}
function addTag() {
const tagNames = inputValue.split(/[,;]/);
tagNames.forEach((tagName) => {
const formattedName = tagName
.trim()
.replace(/\s\s+/g, ' ')
.replace(/\t|\\t|\r|\\r|\n|\\n/g, '');
if (formattedName === '') {
return;
}
const hasTagExists = list.items.find(
({ name }) =>
name.toLocaleLowerCase() === formattedName.toLocaleLowerCase(),
);
if (!hasTagExists) {
const tag = {
id: (list.items.at(-1)?.id || 0) + 1,
name: formattedName,
};
list.append(tag);
onTagAdd?.(tag);
}
});
setInputValue('');
}
function handleRemove(keys: Set<Key>) {
list.remove(...keys);
const item = list.getItem([...keys][0]);
if (item) {
onTagRemove?.(item);
}
}
const { id: labelId } = (React.useContext(LabelContext) ?? {}) as {
id?: string;
};
return (
<TagGroup
aria-labelledby={labelId}
onRemove={handleRemove}
className={twMerge(className, 'w-full')}
data-ui="control"
>
<div
className={twMerge(
'flex min-h-9 items-center rounded-md',
'border has-[input[data-focused=true]]:border-ring',
'has-[input[data-invalid=true][data-focused=true]]:border-ring has-[input[data-invalid=true]]:border-destructive',
'has-[input[data-focused=true]]:ring-1 has-[input[data-focused=true]]:ring-ring',
)}
>
<div className="inline-flex flex-1 flex-wrap items-center gap-1 px-2 py-[5px]">
<TagList items={list.items} className="contents">
{(item) => <Tag>{item.name}</Tag>}
</TagList>
<div className="flex flex-1">
<Input
value={inputValue}
onChange={(e) => {
setInputValue(e.target.value);
}}
onKeyDown={handleKeyDown}
className="border-0 px-0.5 py-0 focus:ring-0 sm:py-0"
/>
</div>
</div>
</div>
</TagGroup>
);
}

View file

@ -0,0 +1,62 @@
import { LinkProps, TextProps } from 'react-aria-components';
import { Link } from './link';
import { twMerge } from 'tailwind-merge';
import React from 'react';
import { composeTailwindRenderProps } from './utils';
export function Text({
className,
elementType,
children,
...props
}: TextProps) {
return React.createElement(
elementType ?? 'p',
{
...props,
className: twMerge(
'text-pretty text-base text-muted sm:text-sm/6',
className,
),
},
children,
);
}
export function Strong({
className,
...props
}: React.JSX.IntrinsicElements['strong']) {
return (
<Text
{...props}
elementType="strong"
className={twMerge('font-medium text-foreground', className)}
/>
);
}
export function Small({
className,
...props
}: React.JSX.IntrinsicElements['small']) {
return (
<Text
{...props}
elementType="small"
className={twMerge('text-sm sm:text-xs', className)}
/>
);
}
export function TextLink(props: LinkProps) {
return (
<Link
{...props}
className={composeTailwindRenderProps(
props.className,
'underline underline-offset-4 decoration-zinc-400 dark:decoration-zinc-500',
)}
/>
);
}

View file

@ -0,0 +1,136 @@
@import 'tailwindcss';
@plugin 'tailwindcss-animate';
@plugin '@tailwindcss/container-queries';
@custom-variant dark (&:is(.dark *));
@theme {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-accent: var(--accent);
--color-success: var(--success);
--color-destructive: var(--destructive);
--color-warning: var(--warning);
--color-muted: var(--muted);
--color-input: var(--input);
--color-border: var(--border);
--color-ring: var(--ring);
}
/*
The default border color has changed to `currentColor` in Tailwind CSS v4,
so we've added these compatibility styles to make sure everything still
looks the same as it did with Tailwind CSS v3.
If we ever want to remove these styles, we need to add an explicit border
color utility to any element that depends on these defaults.
*/
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentColor);
}
}
@layer base {
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.141 0.005 285.823);
--accent: oklch(0.21 0.006 285.885);
--input: oklch(0.871 0.006 286.286);
--border: oklch(0.92 0.004 286.32);
--muted: oklch(0.552 0.016 285.938);
--success: oklch(0.527 0.154 150.069);
--destructive: oklch(0.577 0.245 27.325);
--warning: oklch(0.554 0.135 66.442);
--ring: oklch(0.705 0.015 286.067);
}
.dark {
--background: oklch(0.141 0.005 285.823);
--foreground: oklch(1 0 0);
--accent: oklch(1 0 0);
--input: oklch(0.37 0.013 285.805);
--border: oklch(0.274 0.006 286.033);
--muted: oklch(0.705 0.015 286.067);
--success: oklch(0.527 0.154 150.069);
--destructive: oklch(0.505 0.213 27.518);
--warning: oklch(0.554 0.135 66.442);
--ring: oklch(0.552 0.016 285.938);
}
/* Theme */
.blue-theme {
--accent: oklch(0.546 0.245 262.881);
--ring: oklch(0.623 0.214 259.815);
}
.dark.blue-theme {
--accent: oklch(0.546 0.245 262.881);
}
.indigo-theme {
--accent: oklch(0.585 0.233 277.117);
--ring: oklch(0.585 0.233 277.117);
}
.dark.indigo-theme {
--accent: oklch(0.585 0.233 277.117);
}
.violet-theme {
--accent: oklch(0.541 0.281 293.009);
--ring: oklch(0.541 0.281 293.009);
}
.dark.violet-theme {
--accent: oklch(0.541 0.281 293.009);
}
.purple-theme {
--accent: oklch(0.558 0.288 302.321);
--ring: oklch(0.558 0.288 302.321);
}
.dark.purple-theme {
--accent: oklch(0.558 0.288 302.321);
}
}
@layer base {
*,
*:before,
*:after {
@apply border-border;
}
body {
@apply bg-background text-foreground h-full font-sans antialiased;
}
[data-ui='icon'] {
flex-shrink: 0;
}
::-webkit-scrollbar {
width: 5px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--color-border);
border-radius: 5px;
}
* {
scrollbar-width: thin;
scrollbar-color: var(--color-border) transparent;
}
}

View file

@ -0,0 +1,32 @@
import {
TimeField as RACTimeField,
TimeFieldProps as RACTimeFieldProps,
TimeValue,
composeRenderProps,
} from 'react-aria-components';
import { twMerge } from 'tailwind-merge';
import { inputField } from './utils';
export interface TimeFieldProps<T extends TimeValue>
extends RACTimeFieldProps<T> {}
export function TimeField<T extends TimeValue>(props: RACTimeFieldProps<T>) {
return (
<RACTimeField
{...props}
className={composeRenderProps(
props.className,
(className, { isDisabled }) => {
return twMerge(
inputField,
'items-start',
// RAC does not set disable to time field when it is disable
// So we have to style disable state for none input
isDisabled && '[&>:not(input)]:opacity-50',
className,
);
},
)}
/>
);
}

View file

@ -0,0 +1,66 @@
import React from 'react';
export type TimeOption = {
hour: number;
minute: number;
value: string;
id: string;
};
export function useTimePicker({
intervalInMinute,
}: {
intervalInMinute: 15 | 30;
}): Array<TimeOption> {
return React.useMemo(() => {
const options = [];
for (let hour = 0; hour < 24; hour++) {
const period = hour >= 12 ? 'PM' : 'AM';
let hourIn12Format = hour % 12;
if (hourIn12Format === 0) {
hourIn12Format = 12;
}
for (
let interval = 0;
interval < Math.floor(60 / intervalInMinute);
interval++
) {
const minutes = interval * intervalInMinute;
options.push({
hour,
minute: minutes,
value: `${hourIn12Format}:${minutes === 0 ? '00' : minutes} ${period}`,
id: `${hourIn12Format}:${minutes === 0 ? '00' : minutes} ${period}`,
});
}
}
return options;
}, [intervalInMinute]);
}
export function getRoundMinute({
intervalInMinute,
minute,
}: {
intervalInMinute: number;
minute: number;
}) {
const closeMinute = Array(60 / intervalInMinute + 1)
.fill(0)
.map((_, i) => {
return intervalInMinute * i;
})
.find((i) => {
return i > minute;
});
if (closeMinute) {
return closeMinute - minute;
}
return 0;
}

View file

@ -0,0 +1,31 @@
import { ToastQueue } from '@react-stately/toast';
type Position =
| 'top-left'
| 'top-center'
| 'top-right'
| 'bottom-left'
| 'bottom-center'
| 'bottom-right';
type Type = 'info' | 'error' | 'success' | 'warning';
export type ToastConfig =
| {
position?: Position;
title?: React.ReactNode;
description: React.ReactNode;
action?: React.ReactNode;
dismissable?: boolean;
render?: never;
type?: Type;
}
| {
render: () => React.ReactNode;
position?: Position;
type?: Type;
};
export const toast = new ToastQueue<ToastConfig>({
maxVisibleToasts: 5,
});

View file

@ -0,0 +1,222 @@
import React from 'react';
import { createPortal } from 'react-dom';
import { useToastQueue } from '@react-stately/toast';
import type { AriaToastRegionProps, ToastAria } from '@react-aria/toast';
import type { ToastState } from '@react-stately/toast';
import { useToastRegion } from '@react-aria/toast';
import type { AriaToastProps } from '@react-aria/toast';
import { useToast } from '@react-aria/toast';
import {
ButtonProps as AriaButtonProps,
composeRenderProps,
} from 'react-aria-components';
import { Button, ButtonProps } from '../button';
import { twMerge } from 'tailwind-merge';
import { toast, ToastConfig } from './toast-queue';
import {
CircleCheckIcon,
CircleInfoIcon,
CircleXIcon,
OctagonAlertIcon,
XIcon,
} from '../icons';
interface ToastRegionProps extends AriaToastRegionProps {
state: ToastState<ToastConfig>;
}
interface ToastProps extends AriaToastProps<ToastConfig> {
state: ToastState<ToastConfig>;
}
function ToastRegion({ state, ...props }: ToastRegionProps) {
const ref = React.useRef(null);
const { regionProps } = useToastRegion(props, state, ref);
const position =
state.visibleToasts[state.visibleToasts.length - 1].content.position ??
'bottom-right';
let className = 'bottom-6 right-6 anim ';
switch (position) {
case 'bottom-left':
className = 'bottom-6 left-6';
break;
case 'bottom-center':
className = 'bottom-6 left-1/2 -translate-x-1/2';
break;
case 'top-left':
className = 'top-6 left-6 ';
break;
case 'top-center':
className = 'top-6 left-1/2 -translate-x-1/2';
break;
case 'top-right':
className = 'top-6 right-6';
break;
default:
break;
}
return (
<div
{...regionProps}
ref={ref}
className={twMerge(
'toast-region fixed isolate z-20 flex flex-col gap-2 outline-hidden',
className,
)}
>
{state.visibleToasts.map((toast) => (
<Toast key={toast.key} toast={toast} state={state} />
))}
</div>
);
}
export function GlobalToastRegion(props: AriaToastRegionProps) {
const state = useToastQueue<ToastConfig>(toast);
return state.visibleToasts.length > 0
? createPortal(<ToastRegion {...props} state={state} />, document.body)
: null;
}
function Toast({ state, ...props }: ToastProps) {
const ref = React.useRef(null);
const {
toastProps,
titleProps,
closeButtonProps,
descriptionProps,
}: Omit<ToastAria, 'closeButtonProps'> & {
closeButtonProps: Omit<AriaButtonProps, 'children'>;
} = useToast(props, state, ref);
let enteringClassName = '';
const position = props.toast.content.position ?? 'bottom-right';
switch (position) {
case 'bottom-right':
case 'top-right':
enteringClassName =
props.toast.animation === 'entering'
? 'duration-200 slide-in-from-right animate-in ease-out'
: '';
break;
case 'bottom-left':
case 'top-left':
enteringClassName =
props.toast.animation === 'entering'
? 'duration-200 slide-in-from-left animate-in ease-out'
: '';
break;
case 'bottom-center':
case 'top-center':
enteringClassName =
props.toast.animation === 'entering'
? 'duration-200 slide-in-from-top animate-in ease-out'
: '';
break;
default:
break;
}
const type = props.toast.content.type;
return (
<div
{...toastProps}
ref={ref}
className={twMerge(
'relative isolate flex w-[min(85vw,360px)] space-x-1 rounded-lg shadow-xs transition',
'flex flex-1 rounded-ld bg-zinc-900 outline-hidden',
type ? 'px-2.5' : 'px-4',
'py-2.5',
!props.toast.content.render &&
'border border-zinc-950 dark:border-zinc-800',
enteringClassName,
)}
>
{props.toast.content.render ? (
props.toast.content.render()
) : (
<>
<div className="flex flex-1 items-center space-x-2.5 self-center">
{type === 'info' && (
<CircleInfoIcon className="mt-1 size-5 self-start text-blue-500" />
)}
{type === 'error' && (
<CircleXIcon className="mt-1 size-5 self-start text-destructive" />
)}
{type === 'warning' && (
<OctagonAlertIcon className="mt-1 size-5 self-start text-warning" />
)}
{type === 'success' && (
<CircleCheckIcon className="mt-1 size-5 self-start text-success" />
)}
<div className="flex flex-1 flex-col space-y-0.5 text-base/6 sm:text-sm/6">
{props.toast.content.title ? (
<div
{...titleProps}
className={twMerge(
props.toast.content.description &&
'text-sm/6 font-medium text-zinc-50',
)}
>
{props.toast.content.title}
</div>
) : null}
{props.toast.content.description ? (
<div
{...descriptionProps}
className="text-base/5 text-zinc-400 sm:text-sm/5"
>
{props.toast.content.description}
</div>
) : null}
{props.toast.content.action ? (
<div className="flex flex-wrap gap-1 py-1">
{props.toast.content.action}
</div>
) : null}
</div>
</div>
{props.toast.content.dismissable !== false && (
<Button
size="sm"
isIconOnly
variant="plain"
{...closeButtonProps}
className="p rounded-sm text-zinc-400 hover:bg-transparent hover:text-zinc-50"
>
<XIcon aria-label="Close" />
</Button>
)}
</>
)}
</div>
);
}
export function ToastAction({
variant = 'unstyle',
...props
}: ButtonProps) {
return (
<Button
{...props}
variant={variant}
className={composeRenderProps(props.className, (className) => {
return twMerge('text-base/6 text-zinc-50 sm:text-sm/6', className);
})}
/>
);
}

View file

@ -0,0 +1,56 @@
import React from 'react';
import {
Tooltip as RACTooltip,
TooltipProps as RACTooltipProps,
} from 'react-aria-components';
import { composeTailwindRenderProps } from './utils';
import { FocusableOptions, mergeProps, useFocusable } from 'react-aria';
export { TooltipTrigger } from 'react-aria-components';
export interface TooltipProps extends Omit<RACTooltipProps, 'children'> {
children: React.ReactNode;
}
export function Tooltip({ children, ...props }: TooltipProps) {
return (
<RACTooltip
{...props}
offset={6}
className={composeTailwindRenderProps(props.className, [
'group max-w-64 rounded-md px-3 py-1.5',
'text-wrap text-pretty',
'shadow-2xs dark:border dark:shadow-none',
React.Children.toArray(children).every(
(child) => typeof child === 'string',
)
? 'bg-zinc-950 text-xs text-white dark:bg-zinc-800'
: 'border bg-background',
])}
>
{children}
</RACTooltip>
);
}
// https://argos-ci.com/blog/react-aria-migration
export function NonFousableTooltipTarget(props: {
children: React.ReactElement;
}) {
const triggerRef = React.useRef(null);
const { focusableProps } = useFocusable(props.children.props as FocusableOptions, triggerRef);
return React.cloneElement(
props.children,
mergeProps(focusableProps, { tabIndex: 0 }, props.children.props as React.HTMLProps<HTMLElement>, {
ref: triggerRef,
}),
);
}
export function NativeTooltip({
title,
...props
}: React.JSX.IntrinsicElements['div'] & { title: string }) {
return <div title={title} role="presentation" {...props} />;
}

View file

@ -0,0 +1,57 @@
import { composeRenderProps } from 'react-aria-components';
import { ClassNameValue, twMerge } from 'tailwind-merge';
export function composeTailwindRenderProps<T>(
className: string | ((v: T) => string) | undefined,
tw: string | ClassNameValue,
): string | ((v: T) => string) {
return composeRenderProps(className, (className) => twMerge(tw, className));
}
// RAC uses `slot=*`. We use `data-ui=* to avoid potential conflict
export const inputField = [
'group',
// Label style
'[&_[data-ui=label]:not([class*=mb-])]:mb-1',
'[&_[data-ui=label]:not([class*=mb-]):has(+:is(input,textarea,[data-ui=control]))]:mb-2',
// Description style
'[&>:is(input,[data-ui=control])+[data-ui=description]:not([class*=mt-])]:mt-2',
'[&>textarea+[data-ui=description]:not([class*=mt-])]:mt-0.5',
'[&_[data-ui=description]:not([class*=mb-]):has(+:is(input,textarea,[data-ui=control]))]:mb-3',
// Error
'[&>:is(input,textarea,[data-ui=control])+[data-ui=errorMessage]:not([class*=mt-])]:mt-2',
'[&:has([data-ui=description]+[data-ui=errorMessage])_[data-ui=errorMessage]]:mt-1',
].join(' ');
export const groupBox = [
'group flex flex-col',
// Group description style
'[&_[data-ui=description]:not([class*=mt-]):has(+[data-ui=box])]:mt-1',
'[&_[data-ui=description]:not([class*=mt-]):has(+[data-ui=box])]:mb-4',
// Group box style
'[&:not(:has([data-ui=description]+[data-ui=box]))>[data-ui=box]:not([class*=mt-])]:mt-3',
'[&:has(:is([type=checkbox],[type=radio],[role=switch]))_[data-ui=box]:not([class*=gap-])]:gap-y-3',
// Box item description inside
'[&:has(:is([type=checkbox],[type=radio],[role=switch]))_[data-ui=box]:has([data-ui=description]):not([class*=gap-y])]:gap-y-4',
// Horizontal
'[&[data-orientation=horizontal]:has(:is([type=checkbox],[type=radio],[role=switch]))_[data-ui=box]:not([class*=gap-x-])]:gap-x-4',
'[&[data-orientation=horizontal]:has(:is([type=checkbox],[type=radio],[role=switch]))_[data-ui=box]:not([class*=gap-y-])]:gap-y-2',
// Error
'[&:has([data-ui=box]+[data-ui=errorMessage])_[data-ui=errorMessage]]:mt-2',
].join(' ');
export const displayLevels = {
1: 'font-semibold text-2xl',
2: 'font-semibold text-base',
3: 'font-medium text-base sm:text-sm/6',
};
export type DisplayLevel = keyof typeof displayLevels;

View file

@ -1,7 +1,9 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { defineConfig, type PluginOption } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
import { visualizer } from "rollup-plugin-visualizer";
// https://vite.dev/config/
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
})
plugins: [react(), visualizer() as PluginOption, tailwindcss()],
});