Auth page
This commit is contained in:
parent
50ba9b340b
commit
5e4c33a168
69 changed files with 9132 additions and 1296 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
3049
ui/pnpm-lock.yaml
3049
ui/pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
BIN
ui/public/icon.jpg
Normal file
BIN
ui/public/icon.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 42 KiB |
|
|
@ -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;
|
||||
}
|
||||
105
ui/src/App.tsx
105
ui/src/App.tsx
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
172
ui/src/ui-components/avatar.tsx
Normal file
172
ui/src/ui-components/avatar.tsx
Normal 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,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
48
ui/src/ui-components/breadcrumbs.tsx
Normal file
48
ui/src/ui-components/breadcrumbs.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
367
ui/src/ui-components/button.tsx
Normal file
367
ui/src/ui-components/button.tsx
Normal 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)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
279
ui/src/ui-components/calendar.tsx
Normal file
279
ui/src/ui-components/calendar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
171
ui/src/ui-components/checkbox.tsx
Normal file
171
ui/src/ui-components/checkbox.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
94
ui/src/ui-components/clipboard.tsx
Normal file
94
ui/src/ui-components/clipboard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
154
ui/src/ui-components/combobox.tsx
Normal file
154
ui/src/ui-components/combobox.tsx
Normal 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;
|
||||
73
ui/src/ui-components/date-field.tsx
Normal file
73
ui/src/ui-components/date-field.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
128
ui/src/ui-components/date-picker.tsx
Normal file
128
ui/src/ui-components/date-picker.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
161
ui/src/ui-components/date-range-picker.tsx
Normal file
161
ui/src/ui-components/date-range-picker.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
191
ui/src/ui-components/dialog.tsx
Normal file
191
ui/src/ui-components/dialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
66
ui/src/ui-components/disclosure.tsx
Normal file
66
ui/src/ui-components/disclosure.tsx
Normal 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,
|
||||
]);
|
||||
},
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
28
ui/src/ui-components/dropzone.tsx
Normal file
28
ui/src/ui-components/dropzone.tsx
Normal 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,
|
||||
),
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
76
ui/src/ui-components/empty-state.tsx
Normal file
76
ui/src/ui-components/empty-state.tsx
Normal 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,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
200
ui/src/ui-components/field.tsx
Normal file
200
ui/src/ui-components/field.tsx
Normal 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,
|
||||
),
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
1
ui/src/ui-components/file-trigger.tsx
Normal file
1
ui/src/ui-components/file-trigger.tsx
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { FileTrigger } from 'react-aria-components';
|
||||
11
ui/src/ui-components/form.tsx
Normal file
11
ui/src/ui-components/form.tsx
Normal 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)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
74
ui/src/ui-components/grid-list.tsx
Normal file
74
ui/src/ui-components/grid-list.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
61
ui/src/ui-components/heading.tsx
Normal file
61
ui/src/ui-components/heading.tsx
Normal 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)}
|
||||
/>
|
||||
);
|
||||
});
|
||||
35
ui/src/ui-components/hooks/use-clipboard.ts
Normal file
35
ui/src/ui-components/hooks/use-clipboard.ts
Normal 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 };
|
||||
}
|
||||
34
ui/src/ui-components/hooks/use-image-loading-status.ts
Normal file
34
ui/src/ui-components/hooks/use-image-loading-status.ts
Normal 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;
|
||||
}
|
||||
160
ui/src/ui-components/hover-card.tsx
Normal file
160
ui/src/ui-components/hover-card.tsx
Normal 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>;
|
||||
}
|
||||
35
ui/src/ui-components/icon.tsx
Normal file
35
ui/src/ui-components/icon.tsx
Normal 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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
538
ui/src/ui-components/icons.tsx
Normal file
538
ui/src/ui-components/icons.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
47
ui/src/ui-components/initials.ts
Normal file
47
ui/src/ui-components/initials.ts
Normal 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];
|
||||
}
|
||||
27
ui/src/ui-components/kbd.tsx
Normal file
27
ui/src/ui-components/kbd.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
65
ui/src/ui-components/link.tsx
Normal file
65
ui/src/ui-components/link.tsx
Normal 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;
|
||||
},
|
||||
);
|
||||
62
ui/src/ui-components/list-box.tsx
Normal file
62
ui/src/ui-components/list-box.tsx
Normal 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;
|
||||
|
||||
263
ui/src/ui-components/menu.tsx
Normal file
263
ui/src/ui-components/menu.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
95
ui/src/ui-components/meter.tsx
Normal file
95
ui/src/ui-components/meter.tsx
Normal 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';
|
||||
}
|
||||
173
ui/src/ui-components/modal.tsx
Normal file
173
ui/src/ui-components/modal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
366
ui/src/ui-components/multi-select.tsx
Normal file
366
ui/src/ui-components/multi-select.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
77
ui/src/ui-components/native-select.tsx
Normal file
77
ui/src/ui-components/native-select.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
68
ui/src/ui-components/notification-badge.tsx
Normal file
68
ui/src/ui-components/notification-badge.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
72
ui/src/ui-components/number-field.tsx
Normal file
72
ui/src/ui-components/number-field.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
109
ui/src/ui-components/pagination.tsx
Normal file
109
ui/src/ui-components/pagination.tsx
Normal 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)}>
|
||||
…
|
||||
</span>
|
||||
);
|
||||
}
|
||||
53
ui/src/ui-components/password-input.tsx
Normal file
53
ui/src/ui-components/password-input.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
47
ui/src/ui-components/popover.tsx
Normal file
47
ui/src/ui-components/popover.tsx
Normal 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',
|
||||
])}
|
||||
/>
|
||||
);
|
||||
}
|
||||
37
ui/src/ui-components/progress-bar.tsx
Normal file
37
ui/src/ui-components/progress-bar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
170
ui/src/ui-components/radio-group.tsx
Normal file
170
ui/src/ui-components/radio-group.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
118
ui/src/ui-components/range-calendar.tsx
Normal file
118
ui/src/ui-components/range-calendar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
60
ui/src/ui-components/search-field.tsx
Normal file
60
ui/src/ui-components/search-field.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
288
ui/src/ui-components/select.tsx
Normal file
288
ui/src/ui-components/select.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
73
ui/src/ui-components/separator.tsx
Normal file
73
ui/src/ui-components/separator.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
13
ui/src/ui-components/skeleton.tsx
Normal file
13
ui/src/ui-components/skeleton.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
112
ui/src/ui-components/slider.tsx
Normal file
112
ui/src/ui-components/slider.tsx
Normal 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)',
|
||||
};
|
||||
}
|
||||
44
ui/src/ui-components/slot.tsx
Normal file
44
ui/src/ui-components/slot.tsx
Normal 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;
|
||||
}
|
||||
155
ui/src/ui-components/switch.tsx
Normal file
155
ui/src/ui-components/switch.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
187
ui/src/ui-components/table.tsx
Normal file
187
ui/src/ui-components/table.tsx
Normal 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,
|
||||
),
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
203
ui/src/ui-components/tabs.tsx
Normal file
203
ui/src/ui-components/tabs.tsx
Normal 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,
|
||||
);
|
||||
},
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
112
ui/src/ui-components/tag-group.tsx
Normal file
112
ui/src/ui-components/tag-group.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
176
ui/src/ui-components/tag-input.tsx
Normal file
176
ui/src/ui-components/tag-input.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
62
ui/src/ui-components/text.tsx
Normal file
62
ui/src/ui-components/text.tsx
Normal 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',
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
136
ui/src/ui-components/theme/index.css
Normal file
136
ui/src/ui-components/theme/index.css
Normal 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;
|
||||
}
|
||||
}
|
||||
32
ui/src/ui-components/time-field.tsx
Normal file
32
ui/src/ui-components/time-field.tsx
Normal 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,
|
||||
);
|
||||
},
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
66
ui/src/ui-components/time-picker.ts
Normal file
66
ui/src/ui-components/time-picker.ts
Normal 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;
|
||||
}
|
||||
31
ui/src/ui-components/toast/toast-queue.ts
Normal file
31
ui/src/ui-components/toast/toast-queue.ts
Normal 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,
|
||||
});
|
||||
222
ui/src/ui-components/toast/toast-region.tsx
Normal file
222
ui/src/ui-components/toast/toast-region.tsx
Normal 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);
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
56
ui/src/ui-components/tooltip.tsx
Normal file
56
ui/src/ui-components/tooltip.tsx
Normal 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} />;
|
||||
}
|
||||
57
ui/src/ui-components/utils.ts
Normal file
57
ui/src/ui-components/utils.ts
Normal 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;
|
||||
|
|
@ -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()],
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue