chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:32:57 +08:00
commit cd420f9332
4811 changed files with 884702 additions and 0 deletions
@@ -0,0 +1,68 @@
"use client";
import * as React from "react";
import * as AccordionPrimitive from "@radix-ui/react-accordion";
import { ChevronDown } from "lucide-react";
import { cn } from "~/utils/cn";
import { Icon, type RenderIcon } from "./Icon";
const Accordion = AccordionPrimitive.Root;
const AccordionItem = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
>(({ className, ...props }, ref) => (
<AccordionPrimitive.Item
ref={ref}
className={cn("group rounded border border-grid-bright transition-colors", className)}
{...props}
/>
));
AccordionItem.displayName = "AccordionItem";
type AccordionTriggerProps = React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger> & {
leadingIcon?: RenderIcon;
leadingIconClassName?: string;
};
const AccordionTrigger = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Trigger>,
AccordionTriggerProps
>(({ className, children, leadingIcon, leadingIconClassName, ...props }, ref) => (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
ref={ref}
className={cn(
"flex flex-1 items-center justify-between py-2 pl-2 pr-3 text-sm text-text-bright transition group-hover:border-grid-bright hover:bg-grid-dimmed [&[data-state=open]>svg]:rotate-180",
className
)}
{...props}
>
<div className="flex items-center gap-1">
{leadingIcon && (
<Icon icon={leadingIcon} className={cn("size-5 shrink-0", leadingIconClassName)} />
)}
<div className="pl-0.5">{children}</div>
</div>
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
));
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName;
const AccordionContent = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Content
ref={ref}
className="overflow-hidden border-t border-grid-bright px-3 text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
{...props}
>
<div className={cn("py-3", className)}>{children}</div>
</AccordionPrimitive.Content>
));
AccordionContent.displayName = AccordionPrimitive.Content.displayName;
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
@@ -0,0 +1,116 @@
"use client";
import * as React from "react";
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
import { cn } from "~/utils/cn";
const Alert = AlertDialogPrimitive.Root;
const AlertTrigger = AlertDialogPrimitive.Trigger;
const AlertPortal = ({ children, ...props }: AlertDialogPrimitive.AlertDialogPortalProps) => (
<AlertDialogPrimitive.Portal {...props}>
<div className="fixed inset-0 z-50 flex items-end justify-center sm:items-center">
{children}
</div>
</AlertDialogPrimitive.Portal>
);
AlertPortal.displayName = AlertDialogPrimitive.Portal.displayName;
const AlertOverlay = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, children, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-background-dimmed/80 backdrop-blur-sm transition-opacity animate-in fade-in",
className
)}
{...props}
ref={ref}
/>
));
AlertOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
const AlertContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertPortal>
<AlertOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
"fixed z-50 grid w-full max-w-lg scale-100 gap-4 border bg-background-dimmed p-6 opacity-100 shadow-lg animate-in fade-in-90 slide-in-from-bottom-10 sm:rounded-lg sm:zoom-in-90 sm:slide-in-from-bottom-0 md:w-full",
className
)}
{...props}
/>
</AlertPortal>
));
AlertContent.displayName = AlertDialogPrimitive.Content.displayName;
const AlertHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col space-y-2 text-center sm:text-left", className)} {...props} />
);
AlertHeader.displayName = "AlertDialogHeader";
const AlertFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)}
{...props}
/>
);
AlertFooter.displayName = "AlertDialogFooter";
const AlertTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold", className)}
{...props}
/>
));
AlertTitle.displayName = AlertDialogPrimitive.Title.displayName;
const AlertDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
));
AlertDescription.displayName = AlertDialogPrimitive.Description.displayName;
const AlertAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action ref={ref} className={cn(className)} {...props} />
));
AlertAction.displayName = AlertDialogPrimitive.Action.displayName;
const AlertCancel = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel ref={ref} className={cn("mt-2 sm:mt-0", className)} {...props} />
));
AlertCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
export {
Alert,
AlertTrigger,
AlertContent,
AlertHeader,
AlertFooter,
AlertTitle,
AlertDescription,
AlertAction,
AlertCancel,
};
@@ -0,0 +1,94 @@
import { useEffect, useRef, useState } from "react";
import { Callout, type CalloutVariant } from "~/components/primitives/Callout";
import { cn } from "~/utils/cn";
const CALLOUT_ANIMATION_MS = 300;
type AnimatedCalloutProps = {
show: boolean;
variant: CalloutVariant;
className?: string;
children: React.ReactNode;
/** When set, the callout auto-hides after this many milliseconds. */
autoHideMs?: number;
onAutoHide?: () => void;
onHidden?: () => void;
};
export function AnimatedCallout({
show,
variant,
className,
children,
autoHideMs,
onAutoHide,
onHidden,
}: AnimatedCalloutProps) {
const [rendered, setRendered] = useState(show);
const [autoDismissed, setAutoDismissed] = useState(false);
const onAutoHideRef = useRef(onAutoHide);
const onHiddenRef = useRef(onHidden);
useEffect(() => {
onAutoHideRef.current = onAutoHide;
}, [onAutoHide]);
useEffect(() => {
onHiddenRef.current = onHidden;
}, [onHidden]);
const shouldShow = show && !autoDismissed;
useEffect(() => {
if (!show) {
setAutoDismissed(false);
}
}, [show]);
useEffect(() => {
if (shouldShow) {
setRendered(true);
return;
}
if (!rendered) {
return;
}
const hideTimer = window.setTimeout(() => {
setRendered(false);
onHiddenRef.current?.();
}, CALLOUT_ANIMATION_MS);
return () => window.clearTimeout(hideTimer);
}, [shouldShow, rendered]);
useEffect(() => {
if (!shouldShow || autoHideMs === undefined) {
return;
}
const closeTimer = window.setTimeout(() => {
setAutoDismissed(true);
onAutoHideRef.current?.();
}, autoHideMs);
return () => window.clearTimeout(closeTimer);
}, [shouldShow, autoHideMs]);
if (!rendered) {
return null;
}
return (
<div
aria-hidden={!shouldShow}
className={cn(
"transition-opacity duration-300",
shouldShow ? "opacity-100" : "pointer-events-none opacity-0",
className
)}
>
<Callout variant={variant}>{children}</Callout>
</div>
);
}
@@ -0,0 +1,71 @@
import { animate, motion, useMotionValue, useTransform } from "framer-motion";
import { useEffect, useMemo } from "react";
/**
* Determines the number of decimal places to display based on the value.
* - For integers or large numbers (>=100), no decimals
* - For numbers >= 10, 1 decimal place
* - For numbers >= 1, 2 decimal places
* - For smaller numbers, up to 4 decimal places
*/
function getDecimalPlaces(value: number): number {
if (Number.isInteger(value)) return 0;
const absValue = Math.abs(value);
if (absValue >= 100) return 0;
if (absValue >= 10) return 1;
if (absValue >= 1) return 2;
if (absValue >= 0.1) return 3;
return 4;
}
/**
* Sanitizes a decimal places value to ensure it's valid for toLocaleString.
* - Coerces to a finite number (handles NaN, Infinity, -Infinity)
* - Rounds to an integer
* - Clamps to the valid 0-20 range for toLocaleString options
*/
function sanitizeDecimals(decimals: number): number {
if (!Number.isFinite(decimals)) {
return 0;
}
return Math.min(20, Math.max(0, Math.round(decimals)));
}
export function AnimatedNumber({
value,
duration = 0.5,
decimalPlaces,
}: {
value: number;
duration?: number;
/** Number of decimal places to display. If not provided, auto-detects based on value. */
decimalPlaces?: number;
}) {
const motionValue = useMotionValue(value);
// Determine decimal places - use provided value or auto-detect, then sanitize
const safeDecimals = useMemo(() => {
const rawDecimals = decimalPlaces !== undefined ? decimalPlaces : getDecimalPlaces(value);
return sanitizeDecimals(rawDecimals);
}, [decimalPlaces, value]);
const display = useTransform(motionValue, (current) => {
if (safeDecimals === 0) {
return Math.round(current).toLocaleString();
}
return current.toLocaleString(undefined, {
minimumFractionDigits: safeDecimals,
maximumFractionDigits: safeDecimals,
});
});
useEffect(() => {
animate(motionValue, value, {
duration,
ease: "easeInOut",
});
}, [value, duration]);
return <motion.span>{display}</motion.span>;
}
@@ -0,0 +1,177 @@
import { ChevronLeftIcon, ChevronRightIcon } from "@heroicons/react/20/solid";
import { cn } from "~/utils/cn";
const variants = {
small: {
size: "size-4",
arrowHeadRight: "group-hover:translate-x-[3px]",
arrowLineRight: "h-[1.5px] w-[7px] translate-x-1 top-[calc(50%-0.5px)]",
arrowHeadLeft: "group-hover:translate-x-[3px]",
arrowLineLeft: "h-[1.5px] w-[7px] translate-x-1 top-[calc(50%-0.5px)]",
arrowHeadTopRight:
"translate-x-0 transition group-hover:translate-x-[3px] group-hover:translate-y-[-3px]",
},
medium: {
size: "size-[1.1rem]",
arrowHeadRight: "group-hover:translate-x-[3px]",
arrowLineRight: "h-[1.5px] w-[9px] translate-x-1 top-[calc(50%-1px)]",
arrowHeadLeft: "group-hover:translate-x-[-3px]",
arrowLineLeft: "h-[1.5px] w-[9px] translate-x-1 top-[calc(50%-1px)]",
arrowHeadTopRight:
"translate-x-0 transition group-hover:translate-x-[3px] group-hover:translate-y-[-3px]",
},
large: {
size: "size-6",
arrowHeadRight: "group-hover:translate-x-1",
arrowLineRight: "h-[2.3px] w-[12px] translate-x-[6px] top-[calc(50%-1px)]",
arrowHeadLeft: "group-hover:translate-x-1",
arrowLineLeft: "h-[2.3px] w-[12px] translate-x-[6px] top-[calc(50%-1px)]",
arrowHeadTopRight:
"translate-x-0 transition group-hover:translate-x-[3px] group-hover:translate-y-[-3px]",
},
"extra-large": {
size: "size-8",
arrowHeadRight: "group-hover:translate-x-1",
arrowLineRight: "h-[3px] w-[16px] translate-x-[8px] top-[calc(50%-1.5px)]",
arrowHeadLeft: "group-hover:translate-x-1",
arrowLineLeft: "h-[3px] w-[16px] translate-x-[8px] top-[calc(50%-1.5px)]",
arrowHeadTopRight:
"translate-x-0 transition group-hover:translate-x-[3px] group-hover:translate-y-[-3px]",
},
};
export const themes = {
dark: {
textStyle: "text-background-bright",
arrowLine: "bg-background-bright",
},
dimmed: {
textStyle: "text-text-dimmed",
arrowLine: "bg-text-dimmed",
},
bright: {
textStyle: "text-text-bright",
arrowLine: "bg-text-bright",
},
primary: {
textStyle: "text-text-dimmed group-hover:text-primary",
arrowLine: "bg-text-dimmed group-hover:bg-primary",
},
blue: {
textStyle: "text-text-dimmed group-hover:text-blue-500",
arrowLine: "bg-text-dimmed group-hover:bg-blue-500",
},
rose: {
textStyle: "text-text-dimmed group-hover:text-rose-500",
arrowLine: "bg-text-dimmed group-hover:bg-rose-500",
},
amber: {
textStyle: "text-text-dimmed group-hover:text-amber-500",
arrowLine: "bg-text-dimmed group-hover:bg-amber-500",
},
apple: {
textStyle: "text-text-dimmed group-hover:text-apple-500",
arrowLine: "bg-text-dimmed group-hover:bg-apple-500",
},
lavender: {
textStyle: "text-text-dimmed group-hover:text-lavender-500",
arrowLine: "bg-text-dimmed group-hover:bg-lavender-500",
},
};
type Variants = keyof typeof variants;
type Theme = keyof typeof themes;
type AnimatingArrowProps = {
className?: string;
variant?: Variants;
theme?: Theme;
direction?: "right" | "left" | "topRight";
};
export function AnimatingArrow({
className,
variant = "medium",
theme = "dimmed",
direction = "right",
}: AnimatingArrowProps) {
const variantStyles = variants[variant];
const themeStyles = themes[theme];
return (
<span className={cn("relative -mr-1 ml-1 flex", variantStyles.size, className)}>
{direction === "topRight" && (
<>
<svg
className={cn(
"absolute top-[5px] transition duration-200 ease-in-out",
themeStyles.textStyle
)}
width="9"
height="8"
viewBox="0 0 9 8"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M1.5 7L7.5 1" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
<svg
className={cn(
"absolute top-[5px] transition duration-300 ease-in-out",
themeStyles.textStyle,
variantStyles.arrowHeadTopRight
)}
width="9"
height="8"
viewBox="0 0 9 8"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M1 1H7.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
<path d="M7.5 7L7.5 1" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
<path d="M1 7.5L7.5 1" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
</>
)}
{direction === "right" && (
<>
<span
className={cn(
"absolute rounded-full opacity-0 transition duration-300 ease-in-out group-hover:opacity-100",
variantStyles.arrowLineRight,
themeStyles.arrowLine
)}
/>
<ChevronRightIcon
className={cn(
"absolute -translate-x-0.5 transition duration-300 ease-in-out",
variantStyles.arrowHeadRight,
variantStyles.size,
themeStyles.textStyle
)}
/>
</>
)}
{direction === "left" && (
<>
<span
className={cn(
"absolute rounded-full opacity-0 transition duration-300 ease-in-out group-hover:opacity-100",
variantStyles.arrowLineLeft,
themeStyles.arrowLine
)}
/>
<ChevronLeftIcon
className={cn(
"absolute translate-x-0.5 transition duration-300 ease-in-out",
variantStyles.arrowHeadLeft,
variantStyles.size,
themeStyles.textStyle
)}
/>
</>
)}
</span>
);
}
@@ -0,0 +1,81 @@
import { XMarkIcon } from "@heroicons/react/20/solid";
import { type ReactNode } from "react";
import { cn } from "~/utils/cn";
const variants = {
"secondary/small": {
box: "h-6 bg-secondary rounded pl-1.5 gap-1.5 text-xs divide-x divide-black/15 group-hover:bg-surface-control group-hover:border-border-brighter text-text-bright border border-border-bright",
clear: "size-6 text-text-bright hover:text-text-bright transition-colors",
},
"tertiary/small": {
box: "h-6 bg-tertiary rounded pl-1.5 gap-1.5 text-xs divide-x divide-black/15 group-hover:bg-surface-control",
clear: "size-6 text-text-dimmed hover:text-text-bright transition-colors",
},
"minimal/medium": {
box: "rounded gap-1.5 text-sm",
clear: "size-6 text-text-dimmed transition-colors",
},
};
type Variant = keyof typeof variants;
type AppliedFilterProps = {
icon?: ReactNode;
label?: ReactNode;
value: ReactNode;
removable?: boolean;
onRemove?: () => void;
variant?: Variant;
className?: string;
valueClassName?: string;
};
export function AppliedFilter({
icon,
label,
value,
removable = true,
onRemove,
variant = "secondary/small",
className,
valueClassName,
}: AppliedFilterProps) {
const variantClassName = variants[variant];
return (
<div
className={cn(
"flex items-center transition",
variantClassName.box,
!removable && "pr-2",
className
)}
>
<div
className={cn("flex items-start leading-4", label === undefined ? "gap-1.5" : "gap-0.5")}
>
<div className="mt-[-0.5px] flex items-center gap-1.5">
{icon}
{label && (
<div className="text-text-bright">
<span>{label}</span>:
</div>
)}
</div>
<div className={cn("text-text-dimmed", valueClassName)}>
<div>{value}</div>
</div>
</div>
{removable && (
<button
className={cn(
"group flex size-6 items-center justify-center focus-custom",
variantClassName.clear
)}
onClick={onRemove}
>
<XMarkIcon className="size-3.5" />
</button>
)}
</div>
);
}
@@ -0,0 +1,203 @@
import {
BoltIcon,
BuildingOffice2Icon,
CodeBracketSquareIcon,
FaceSmileIcon,
FireIcon,
GlobeAltIcon,
RocketLaunchIcon,
StarIcon,
} from "@heroicons/react/20/solid";
import type { Prisma } from "@trigger.dev/database";
import { z } from "zod";
import { logger } from "~/services/logger.server";
import { cn } from "~/utils/cn";
export const AvatarType = z.enum(["icon", "letters", "image"]);
export const AvatarData = z.discriminatedUnion("type", [
z.object({
type: z.literal(AvatarType.enum.icon),
name: z.string(),
hex: z.string(),
}),
z.object({
type: z.literal(AvatarType.enum.letters),
hex: z.string(),
}),
z.object({
type: z.literal(AvatarType.enum.image),
url: z.string(),
lastIconHex: z.string().optional(),
}),
]);
export type Avatar = z.infer<typeof AvatarData>;
export type IconAvatar = Extract<Avatar, { type: "icon" }>;
export type ImageAvatar = Extract<Avatar, { type: "image" }>;
export type LettersAvatar = Extract<Avatar, { type: "letters" }>;
export function parseAvatar(json: Prisma.JsonValue, defaultAvatar: Avatar): Avatar {
if (!json || typeof json !== "object") {
return defaultAvatar;
}
const parsed = AvatarData.safeParse(json);
if (!parsed.success) {
logger.error("Invalid org avatar", { json, error: parsed.error });
return defaultAvatar;
}
return parsed.data;
}
export function Avatar({
avatar,
size,
includePadding,
orgName,
}: {
avatar: Avatar;
/** Size in rems of the icon */
size: number;
includePadding?: boolean;
orgName: string;
}) {
switch (avatar.type) {
case "icon":
return <AvatarIcon avatar={avatar} size={size} includePadding={includePadding} />;
case "letters":
return (
<AvatarLetters
avatar={avatar}
size={size}
includePadding={includePadding}
orgName={orgName}
/>
);
case "image":
return <AvatarImage avatar={avatar} size={size} />;
}
}
export const avatarIcons: Record<string, React.ComponentType<React.SVGProps<SVGSVGElement>>> = {
"hero:building-office-2": BuildingOffice2Icon,
"hero:rocket-launch": RocketLaunchIcon,
"hero:code-bracket-square": CodeBracketSquareIcon,
"hero:fire": FireIcon,
"hero:star": StarIcon,
"hero:face-smile": FaceSmileIcon,
"hero:bolt": BoltIcon,
};
export const defaultAvatarColors = [
{ hex: "#878C99", name: "Gray" },
{ hex: "#713F12", name: "Brown" },
{ hex: "#F97316", name: "Orange" },
{ hex: "#EAB308", name: "Yellow" },
{ hex: "#22C55E", name: "Green" },
{ hex: "#3B82F6", name: "Blue" },
{ hex: "#6366F1", name: "Purple" },
{ hex: "#EC4899", name: "Pink" },
{ hex: "#F43F5E", name: "Red" },
];
// purple
export const defaultAvatarHex = defaultAvatarColors[6].hex;
export const defaultAvatar: Avatar = {
type: "letters",
hex: defaultAvatarHex,
};
function styleFromSize(size: number) {
return {
width: `${size}rem`,
height: `${size}rem`,
};
}
function AvatarLetters({
avatar,
size,
includePadding,
orgName,
}: {
avatar: LettersAvatar;
size: number;
includePadding?: boolean;
orgName: string;
}) {
const letters = orgName.slice(0, 2);
const style = {
backgroundColor: avatar.hex,
};
const scaleFactor = includePadding ? 0.8 : 1;
return (
<span
className="grid shrink-0 place-items-center overflow-hidden text-charcoal-750"
style={styleFromSize(size)}
>
{/* This is the square container */}
<span
className={cn(
"relative grid place-items-center overflow-hidden rounded-[10%] font-semibold",
includePadding ? "size-[80%]" : "size-full"
)}
style={style}
>
<span
className="font-bold leading-none"
style={{ fontSize: `${size * 0.6 * scaleFactor}rem` }}
>
{letters}
</span>
</span>
</span>
);
}
function AvatarIcon({
avatar,
size,
includePadding,
}: {
avatar: IconAvatar;
size: number;
includePadding?: boolean;
}) {
const style = {
color: avatar.hex,
};
const IconComponent = avatarIcons[avatar.name];
return (
<span className="grid aspect-square place-items-center" style={styleFromSize(size)}>
<IconComponent className={includePadding ? "size-[80%]" : "size-full"} style={style} />
</span>
);
}
function AvatarImage({ avatar, size }: { avatar: ImageAvatar; size: number }) {
if (!avatar.url) {
return (
<span className="grid shrink-0 place-items-center" style={styleFromSize(size)}>
<GlobeAltIcon className="size-[90%] text-text-dimmed" />
</span>
);
}
return (
<span className="grid shrink-0 place-items-center" style={styleFromSize(size)}>
<img
src={avatar.url}
alt="Organization avatar"
className="size-full rounded-[10%] object-contain"
/>
</span>
);
}
@@ -0,0 +1,27 @@
import * as React from "react";
import { cn } from "~/utils/cn";
const variants = {
default:
"grid place-items-center rounded-full px-2 h-5 tracking-wider text-xxs bg-background-hover text-text-bright uppercase whitespace-nowrap",
"extra-small":
"grid place-items-center border border-border-bright rounded-sm px-1 h-4 text-xxs bg-background-bright text-blue-500 whitespace-nowrap",
small:
"grid place-items-center border border-border-bright rounded-sm px-1 h-5 text-xs bg-background-bright text-blue-500 whitespace-nowrap",
"outline-rounded":
"grid place-items-center rounded-full px-1 h-4 tracking-wider text-xxs border border-blue-500 text-blue-500 uppercase whitespace-nowrap",
rounded:
"grid place-items-center rounded-full px-1.5 h-4 text-xxs border bg-blue-600 text-text-bright uppercase whitespace-nowrap",
};
type BadgeProps = React.HTMLAttributes<HTMLDivElement> & {
variant?: keyof typeof variants;
};
export function Badge({ className, variant = "default", children, ...props }: BadgeProps) {
return (
<div className={cn(variants[variant], className)} {...props}>
<span>{children}</span>
</div>
);
}
@@ -0,0 +1,24 @@
import { cn } from "~/utils/cn";
export function BreadcrumbIcon({ className }: { className?: string }) {
return (
<svg
className={cn("text-charcoal-600", className)}
width="9"
height="20"
viewBox="0 0 9 26"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<line
x1="9"
y1="0.7"
x2="0.7"
y2="25"
strokeWidth={1.4}
stroke="currentColor"
strokeLinecap="round"
/>
</svg>
);
}
@@ -0,0 +1,500 @@
import { Link, type LinkProps, NavLink, type NavLinkProps } from "@remix-run/react";
import React, {
forwardRef,
type ReactNode,
useEffect,
useImperativeHandle,
useRef,
useState,
} from "react";
import { type ShortcutDefinition, useShortcutKeys } from "~/hooks/useShortcutKeys";
import { cn } from "~/utils/cn";
import { ShortcutKey } from "./ShortcutKey";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./Tooltip";
import { Icon, type RenderIcon } from "./Icon";
import { Spinner } from "./Spinner";
const sizes = {
small: {
button: "h-6 px-2.5 text-xs",
icon: "h-3.5 -mx-1",
iconSpacing: "gap-x-2.5",
shortcutVariant: "small" as const,
shortcut: "-ml-0.5 -mr-1.5 justify-self-center",
},
medium: {
button: "h-8 px-3 text-sm",
icon: "h-4 -mx-1",
iconSpacing: "gap-x-2.5",
shortcutVariant: "medium" as const,
shortcut: "-ml-0.5 -mr-1.5 rounded justify-self-center",
},
large: {
button: "h-10 px-2 text-base font-medium",
icon: "h-5",
iconSpacing: "gap-x-0.5",
shortcutVariant: "medium" as const,
shortcut: "ml-1.5 -mr-0.5",
},
"extra-large": {
button: "h-12 px-2 text-base font-medium",
icon: "h-5",
iconSpacing: "gap-x-0.5",
shortcutVariant: "medium" as const,
shortcut: "ml-1.5 -mr-0.5",
},
};
type Size = keyof typeof sizes;
const theme = {
primary: {
textColor:
"text-text-bright group-hover/button:text-white transition group-disabled/button:text-text-dimmed",
button:
"bg-indigo-600 border border-indigo-500 group-hover/button:bg-indigo-500 group-hover/button:border-indigo-400 group-disabled/button:opacity-50 group-disabled/button:bg-indigo-600 group-disabled/button:border-indigo-500 group-disabled/button:pointer-events-none",
shortcut:
"border-text-bright/40 text-text-bright group-hover/button:border-text-bright/60 group-hover/button:text-text-bright",
icon: "text-text-bright",
},
secondary: {
textColor: "text-text-bright transition group-disabled/button:text-text-dimmed/80",
button:
"bg-secondary group-hover/button:bg-surface-control group-hover/button:border-border-brighter border border-border-bright group-disabled/button:bg-secondary group-disabled/button:opacity-60 group-disabled/button:pointer-events-none",
shortcut:
"border-text-dimmed/40 text-text-dimmed group-hover/button:text-text-bright group-hover/button:border-text-dimmed",
icon: "text-text-bright",
},
tertiary: {
textColor: "text-text-bright transition group-disabled/button:text-text-dimmed/80",
button:
"bg-tertiary group-hover/button:bg-surface-control group-disabled/button:bg-tertiary group-disabled/button:opacity-60 group-disabled/button:pointer-events-none",
shortcut:
"border-text-dimmed/40 text-text-dimmed group-hover/button:text-text-bright group-hover/button:border-text-dimmed",
icon: "text-text-bright",
},
minimal: {
textColor: "text-text-dimmed group-disabled/button:text-text-dimmed transition",
button:
"bg-transparent group-hover/button:bg-tertiary disabled:opacity-50 group-disabled/button:bg-transparent group-disabled/button:pointer-events-none",
shortcut:
"border-dimmed/40 text-text-dimmed group-hover/button:text-text-bright/80 group-hover/button:border-dimmed/60",
icon: "text-text-dimmed",
},
danger: {
textColor:
"text-text-bright group-hover/button:text-white transition group-disabled/button:text-text-bright/80",
button:
"bg-error group-hover/button:bg-rose-500 disabled:opacity-50 group-disabled/button:bg-error group-disabled/button:pointer-events-none",
shortcut: "border-text-bright text-text-bright group-hover/button:border-text-bright/60",
icon: "text-text-bright",
},
docs: {
textColor: "text-blue-200/70 transition group-disabled/button:text-text-dimmed/80",
button:
"bg-background-raised border border-border-bright/50 shadow-sm group-hover/button:bg-secondary group-disabled/button:bg-tertiary group-disabled/button:opacity-60 group-disabled/button:pointer-events-none",
shortcut:
"border-text-dimmed/40 text-text-dimmed group-hover/button:text-text-bright group-hover/button:border-text-dimmed",
icon: "text-blue-500",
},
};
type Theme = keyof typeof theme;
function createVariant(sizeName: Size, themeName: Theme) {
return {
textColor: theme[themeName].textColor,
button: cn(sizes[sizeName].button, theme[themeName].button),
icon: cn(sizes[sizeName].icon, theme[themeName].icon),
iconSpacing: sizes[sizeName].iconSpacing,
shortcutVariant: sizes[sizeName].shortcutVariant,
shortcut: cn(sizes[sizeName].shortcut, theme[themeName].shortcut),
};
}
const variant = {
"primary/small": createVariant("small", "primary"),
"primary/medium": createVariant("medium", "primary"),
"primary/large": createVariant("large", "primary"),
"primary/extra-large": createVariant("extra-large", "primary"),
"secondary/small": createVariant("small", "secondary"),
"secondary/medium": createVariant("medium", "secondary"),
"secondary/large": createVariant("large", "secondary"),
"secondary/extra-large": createVariant("extra-large", "secondary"),
"tertiary/small": createVariant("small", "tertiary"),
"tertiary/medium": createVariant("medium", "tertiary"),
"tertiary/large": createVariant("large", "tertiary"),
"tertiary/extra-large": createVariant("extra-large", "tertiary"),
"minimal/small": createVariant("small", "minimal"),
"minimal/medium": createVariant("medium", "minimal"),
"minimal/large": createVariant("large", "minimal"),
"minimal/extra-large": createVariant("extra-large", "minimal"),
"danger/small": createVariant("small", "danger"),
"danger/medium": createVariant("medium", "danger"),
"danger/large": createVariant("large", "danger"),
"danger/extra-large": createVariant("extra-large", "danger"),
"docs/small": createVariant("small", "docs"),
"docs/medium": createVariant("medium", "docs"),
"docs/large": createVariant("large", "docs"),
"docs/extra-large": createVariant("extra-large", "docs"),
"menu-item": {
textColor: "text-text-bright px-1",
button:
"h-9 px-[0.475rem] text-sm rounded-sm bg-transparent group-hover/button:bg-background-hover",
icon: "h-5",
iconSpacing: "gap-x-0.5",
shortcutVariant: undefined,
shortcut: undefined,
},
"small-menu-item": {
textColor: "text-text-bright",
button:
"h-[1.8rem] px-[0.4rem] text-2sm rounded-sm text-text-dimmed bg-transparent group-hover/button:bg-background-hover",
icon: "h-[1.125rem]",
iconSpacing: "gap-x-1.5",
shortcutVariant: undefined,
shortcut: undefined,
},
"small-menu-sub-item": {
textColor: "text-text-dimmed",
button:
"h-[1.8rem] px-2 ml-5 text-2sm rounded-sm text-text-dimmed bg-transparent group-hover/button:bg-background-hover focus-custom",
icon: undefined,
iconSpacing: undefined,
shortcutVariant: undefined,
shortcut: undefined,
},
};
const allVariants = {
$all: "font-normal text-center font-sans justify-center items-center shrink-0 transition duration-150 rounded-[3px] select-none group-focus/button:outline-hidden group-disabled/button:opacity-75 group-disabled/button:pointer-events-none focus-custom",
variant: variant,
};
export type ButtonVariant = keyof typeof variant;
export type ButtonContentPropsType = {
children?: React.ReactNode;
LeadingIcon?: RenderIcon;
TrailingIcon?: RenderIcon;
trailingIconClassName?: string;
leadingIconClassName?: string;
fullWidth?: boolean;
textAlignLeft?: boolean;
className?: string;
shortcut?: ShortcutDefinition;
variant: ButtonVariant;
shortcutPosition?: "before-trailing-icon" | "after-trailing-icon";
tooltip?: ReactNode;
iconSpacing?: string;
hideShortcutKey?: boolean;
isLoading?: boolean;
};
export function ButtonContent(props: ButtonContentPropsType) {
const {
children: text,
LeadingIcon,
TrailingIcon,
trailingIconClassName,
leadingIconClassName,
shortcut,
fullWidth,
textAlignLeft,
className,
tooltip,
iconSpacing,
hideShortcutKey,
isLoading,
} = props;
const [showSpinner, setShowSpinner] = useState(false);
useEffect(() => {
if (!isLoading) {
setShowSpinner(false);
return;
}
const timer = setTimeout(() => setShowSpinner(true), 200);
return () => clearTimeout(timer);
}, [isLoading]);
const variation = allVariants.variant[props.variant];
const btnClassName = cn(allVariants.$all, variation.button);
const iconClassName = variation.icon;
const iconSpacingClassName = variation.iconSpacing;
const shortcutClassName = variation.shortcut;
const textColorClassName = variation.textColor;
const renderShortcutKey = () =>
shortcut &&
!hideShortcutKey && (
<ShortcutKey
className={cn(shortcutClassName)}
shortcut={shortcut}
variant={variation.shortcutVariant ?? "medium"}
/>
);
const buttonContent = (
<div className={cn("flex", fullWidth ? "" : "w-fit text-xxs", btnClassName, className)}>
<div className={cn("relative", "flex w-full items-center")}>
<div
className={cn(
textAlignLeft ? "text-left" : "justify-center",
"flex w-full items-center",
iconSpacingClassName,
iconSpacing,
showSpinner && "invisible"
)}
>
{LeadingIcon && (
<Icon
icon={LeadingIcon}
className={cn(
iconClassName,
variation.icon,
leadingIconClassName,
"shrink-0 justify-start"
)}
/>
)}
{text &&
(typeof text === "string" ? (
<span className={cn("mx-auto grow self-center truncate", textColorClassName)}>
{text}
</span>
) : (
<>{text}</>
))}
{shortcut &&
!tooltip &&
props.shortcutPosition === "before-trailing-icon" &&
renderShortcutKey()}
{TrailingIcon && (
<Icon
icon={TrailingIcon}
className={cn(
iconClassName,
variation.icon,
trailingIconClassName,
"shrink-0 justify-end"
)}
/>
)}
{shortcut &&
!tooltip &&
(!props.shortcutPosition || props.shortcutPosition === "after-trailing-icon") &&
renderShortcutKey()}
</div>
{showSpinner && (
<span className="absolute inset-0 flex items-center justify-center">
<Spinner className="size-3.5" color="white" />
</span>
)}
</div>
</div>
);
if (tooltip) {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>{buttonContent}</TooltipTrigger>
<TooltipContent className="flex items-center gap-1.5 py-1.5 pl-2.5 pr-2 text-xs text-text-bright">
{tooltip} {shortcut && renderShortcutKey()}
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
return buttonContent;
}
type ButtonPropsType = Pick<
JSX.IntrinsicElements["button"],
"type" | "disabled" | "onClick" | "name" | "value" | "form" | "autoFocus" | "aria-label"
> &
React.ComponentProps<typeof ButtonContent>;
export const Button = forwardRef<HTMLButtonElement, ButtonPropsType>(
({ type, disabled, autoFocus, onClick, "aria-label": ariaLabel, ...props }, ref) => {
const innerRef = useRef<HTMLButtonElement>(null);
useImperativeHandle(ref, () => innerRef.current as HTMLButtonElement);
const isDisabled = disabled || props.isLoading;
useShortcutKeys({
shortcut: props.shortcut,
action: (e) => {
if (innerRef.current) {
innerRef.current.click();
e.preventDefault();
e.stopPropagation();
}
},
disabled: isDisabled || !props.shortcut,
});
const buttonElement = (
<button
className={cn("group/button outline-hidden focus-custom", props.fullWidth ? "w-full" : "")}
type={type}
disabled={isDisabled}
onClick={onClick}
name={props.name}
value={props.value}
ref={innerRef}
form={props.form}
autoFocus={autoFocus}
aria-label={ariaLabel}
>
<ButtonContent
{...props}
tooltip={undefined}
hideShortcutKey={props.tooltip ? true : props.hideShortcutKey}
/>
</button>
);
if (props.tooltip) {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className={cn("flex", isDisabled && "cursor-default")}>{buttonElement}</span>
</TooltipTrigger>
<TooltipContent className="flex items-center gap-1.5 py-1.5 pl-2.5 pr-2 text-xs text-text-bright">
{props.tooltip}{" "}
{props.shortcut && !props.hideShortcutKey && (
<ShortcutKey shortcut={props.shortcut} variant="medium" />
)}
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
return buttonElement;
}
);
type LinkPropsType = Pick<
LinkProps,
"to" | "target" | "onClick" | "onMouseDown" | "onMouseEnter" | "onMouseLeave" | "download"
> & { disabled?: boolean; replace?: boolean } & React.ComponentProps<typeof ButtonContent>;
export const LinkButton = ({
to,
onClick,
onMouseDown,
onMouseEnter,
onMouseLeave,
download,
disabled = false,
replace,
...props
}: LinkPropsType) => {
const innerRef = useRef<HTMLAnchorElement>(null);
useShortcutKeys({
shortcut: props.shortcut,
action: () => {
if (innerRef.current) {
innerRef.current.click();
}
},
disabled: disabled || !props.shortcut,
});
if (disabled) {
return (
<div
className={cn(
"group/button pointer-events-none cursor-default opacity-40 outline-hidden",
props.fullWidth ? "w-full" : ""
)}
>
<ButtonContent {...props} />
</div>
);
}
if (to.toString().startsWith("http") || to.toString().startsWith("/resources")) {
return (
<ExtLink
href={to.toString()}
ref={innerRef}
className={cn("group/button block focus-custom", props.fullWidth ? "w-full" : "")}
onClick={onClick}
onMouseDown={onMouseDown}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
download={download}
>
<ButtonContent {...props} />
</ExtLink>
);
} else {
return (
<Link
to={to}
ref={innerRef}
replace={replace}
className={cn("group/button block focus-custom", props.fullWidth ? "w-full" : "w-fit")}
onClick={onClick}
onMouseDown={onMouseDown}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
download={download}
>
<ButtonContent {...props} />
</Link>
);
}
};
type NavLinkPropsType = Pick<NavLinkProps, "to" | "target"> &
Omit<React.ComponentProps<typeof ButtonContent>, "className"> & {
className?: (props: { isActive: boolean; isPending: boolean }) => string | undefined;
};
export const NavLinkButton = ({ to, className, target, ...props }: NavLinkPropsType) => {
return (
<NavLink
to={to}
className={cn("group/button outline-hidden block", props.fullWidth ? "w-full" : "")}
target={target}
>
{({ isActive, isPending }) => (
<ButtonContent className={className && className({ isActive, isPending })} {...props} />
)}
</NavLink>
);
};
type ExtLinkProps = JSX.IntrinsicElements["a"] & {
children: React.ReactNode;
className?: string;
href: string;
};
const ExtLink = forwardRef<HTMLAnchorElement, ExtLinkProps>(
({ className, href, children, ...props }, ref) => {
return (
<a
className={cn(className)}
target="_blank"
rel="noopener noreferrer"
href={href}
ref={ref}
{...props}
>
{children}
</a>
);
}
);
@@ -0,0 +1,126 @@
"use client";
import * as React from "react";
import { ChevronLeftIcon, ChevronRightIcon } from "@heroicons/react/20/solid";
import { format } from "date-fns";
import { DayPicker, useDayPicker } from "react-day-picker";
import { cn } from "~/utils/cn";
export type CalendarProps = React.ComponentProps<typeof DayPicker>;
const navButtonClass =
"size-7 rounded-[3px] bg-secondary border border-border-bright text-text-bright hover:bg-surface-control hover:border-border-brighter transition inline-flex items-center justify-center";
function CustomMonthCaption({ calendarMonth }: { calendarMonth: { date: Date } }) {
const { goToMonth, nextMonth, previousMonth } = useDayPicker();
return (
<div className="flex w-full items-center justify-between px-1">
<button
type="button"
className={navButtonClass}
disabled={!previousMonth}
onClick={() => previousMonth && goToMonth(previousMonth)}
aria-label="Go to previous month"
>
<ChevronLeftIcon className="size-4" />
</button>
<div className="flex items-center gap-2">
<select
className="rounded border border-border-bright bg-background-hover px-2 py-1 text-sm text-text-bright focus:border-border-brightest focus:outline-hidden"
value={calendarMonth.date.getMonth()}
onChange={(e) => {
const newDate = new Date(calendarMonth.date);
newDate.setMonth(parseInt(e.target.value));
goToMonth(newDate);
}}
>
{Array.from({ length: 12 }, (_, i) => (
<option key={i} value={i}>
{format(new Date(2000, i), "MMM")}
</option>
))}
</select>
<select
className="rounded border border-border-bright bg-background-hover px-2 py-1 text-sm text-text-bright focus:border-border-brightest focus:outline-hidden"
value={calendarMonth.date.getFullYear()}
onChange={(e) => {
const newDate = new Date(calendarMonth.date);
newDate.setFullYear(parseInt(e.target.value));
goToMonth(newDate);
}}
>
{Array.from({ length: 100 }, (_, i) => {
const year = new Date().getFullYear() - 50 + i;
return (
<option key={year} value={year}>
{year}
</option>
);
})}
</select>
</div>
<button
type="button"
className={navButtonClass}
disabled={!nextMonth}
onClick={() => nextMonth && goToMonth(nextMonth)}
aria-label="Go to next month"
>
<ChevronRightIcon className="size-4" />
</button>
</div>
);
}
export function Calendar({
className,
classNames,
showOutsideDays = true,
...props
}: CalendarProps) {
return (
<DayPicker
showOutsideDays={showOutsideDays}
weekStartsOn={1}
className={cn("p-3", className)}
classNames={{
months: "flex flex-col sm:flex-row gap-2",
month: "flex flex-col gap-4",
month_caption: "flex justify-center pt-1 relative items-center w-full",
caption_label: "sr-only",
nav: "hidden",
month_grid: "w-full border-collapse",
weekdays: "flex",
weekday: "text-text-dimmed rounded-md w-8 font-normal text-[0.8rem]",
week: "flex w-full mt-2",
day: "relative p-0 text-center text-sm focus-within:relative focus-within:z-20 has-aria-[selected]:bg-background-raised [&:has([aria-selected].day-outside)]:bg-background-raised/50 [&:has([aria-selected].day-range-end)]:rounded-r-md first:has-aria-[selected]:rounded-l-md last:has-aria-[selected]:rounded-r-md",
day_button: cn(
"size-8 p-0 font-normal text-text-bright rounded-md",
"hover:bg-background-raised hover:text-text-bright",
"focus:bg-background-raised focus:text-text-bright focus:outline-hidden",
"aria-selected:opacity-100"
),
range_start: "day-range-start rounded-l-md",
range_end: "day-range-end rounded-r-md",
selected:
"bg-indigo-600 text-text-bright hover:bg-indigo-600 hover:text-text-bright focus:bg-indigo-600 focus:text-text-bright rounded-md",
today: "bg-background-raised text-text-bright rounded-md",
outside:
"day-outside text-text-dimmed opacity-50 aria-selected:bg-background-raised/50 aria-selected:text-text-dimmed aria-selected:opacity-30",
disabled: "text-text-dimmed opacity-50",
range_middle: "aria-selected:bg-background-raised aria-selected:text-text-bright",
hidden: "invisible",
dropdowns: "flex gap-2 items-center justify-center",
dropdown:
"bg-background-hover border border-border-bright rounded px-2 py-1 text-sm text-text-bright focus:outline-hidden focus:border-border-brightest",
...classNames,
}}
components={{
MonthCaption: CustomMonthCaption,
}}
{...props}
/>
);
}
Calendar.displayName = "Calendar";
@@ -0,0 +1,169 @@
import {
CreditCardIcon,
ExclamationCircleIcon,
ExclamationTriangleIcon,
InformationCircleIcon,
LightBulbIcon,
} from "@heroicons/react/20/solid";
import {
ArrowTopRightOnSquareIcon,
BookOpenIcon,
CheckCircleIcon,
ChevronRightIcon,
} from "@heroicons/react/24/solid";
import { Link } from "@remix-run/react";
import { cn } from "~/utils/cn";
import { Paragraph } from "./Paragraph";
import { Spinner } from "./Spinner";
export const variantClasses = {
info: {
className: "border-grid-bright bg-background-bright",
icon: <InformationCircleIcon className="h-5 w-5 shrink-0 text-text-dimmed" />,
textColor: "text-text-bright",
linkClassName: "transition hover:bg-background-hover",
},
warning: {
className: "border-warning/20 bg-warning/10",
icon: <ExclamationTriangleIcon className="h-5 w-5 shrink-0 text-warning" />,
textColor: "text-callout-warning-text",
linkClassName: "transition hover:bg-warning/20",
},
error: {
className: "border-error/20 bg-error/10",
icon: <ExclamationCircleIcon className="h-5 w-5 shrink-0 text-error" />,
textColor: "text-callout-error-text",
linkClassName: "transition hover:bg-error/20",
},
idea: {
className: "border-success/20 bg-success/10",
icon: <LightBulbIcon className="h-5 w-5 shrink-0 text-success" />,
textColor: "text-callout-success-text",
linkClassName: "transition hover:bg-success/20",
},
success: {
className: "border-success/20 bg-success/10",
icon: <CheckCircleIcon className="h-5 w-5 shrink-0 text-success" />,
textColor: "text-callout-success-text",
linkClassName: "transition hover:bg-success/20",
},
docs: {
className: "border-callout-docs/20 bg-callout-docs/10",
icon: <BookOpenIcon className="mt-0.5 h-5 w-5 shrink-0 text-callout-docs" />,
textColor: "text-callout-docs-text",
linkClassName: "transition hover:bg-callout-docs/20",
},
pending: {
className: "border-callout-pending/20 bg-callout-pending-bg/30",
icon: <Spinner className="h-5 w-5 shrink-0 " />,
textColor: "text-callout-pending-text",
linkClassName: "transition hover:bg-callout-pending/20",
},
pricing: {
className: "border-callout-pricing/20 bg-callout-pricing-bg/30",
icon: <CreditCardIcon className="h-5 w-5 shrink-0 text-callout-pricing" />,
textColor: "text-callout-pricing-text",
linkClassName: "transition hover:bg-callout-pricing/20",
},
} as const;
export type CalloutVariant = keyof typeof variantClasses;
export function Callout({
children,
className,
icon,
cta,
variant,
to,
}: {
children?: React.ReactNode;
className?: string;
icon?: React.ReactNode;
cta?: React.ReactNode;
variant: CalloutVariant;
to?: string;
}) {
const variantDefinition = variantClasses[variant];
if (to !== undefined) {
if (to.startsWith("http")) {
return (
<a
href={to}
target="_blank"
className={cn(
`flex w-full items-start justify-between gap-2.5 rounded-md border py-2 pl-2 pr-3 shadow-md backdrop-blur-xs`,
variantDefinition.className,
variantDefinition.linkClassName,
className
)}
>
<div className={"flex w-full items-start gap-x-2"}>
{icon ? icon : variantDefinition.icon}
{typeof children === "string" ? (
<Paragraph variant={"small"} className={variantDefinition.textColor}>
{children}
</Paragraph>
) : (
children
)}
</div>
<ArrowTopRightOnSquareIcon className={cn("h-5 w-5", variantDefinition.textColor)} />
</a>
);
} else {
return (
<Link
to={to}
className={cn(
`flex w-full items-start justify-between gap-2.5 rounded-md border py-2 pl-2 pr-3 shadow-md backdrop-blur-xs`,
variantDefinition.className,
variantDefinition.linkClassName,
className
)}
>
<div className={"flex w-full items-start gap-x-2"}>
{icon ? icon : variantDefinition.icon}
{typeof children === "string" ? (
<Paragraph variant={"small"} className={variantDefinition.textColor}>
{children}
</Paragraph>
) : (
children
)}
</div>
<div className="flex h-full items-center">
<ChevronRightIcon className={cn("h-5 w-5", variantDefinition.textColor)} />
</div>
</Link>
);
}
}
return (
<div
className={cn(
"flex w-full items-start gap-2 rounded-md border pl-2 pr-2 shadow-md backdrop-blur-xs",
cta ? "py-2" : "py-2.5",
variantDefinition.className,
className
)}
>
<div className={cn(`flex w-full items-start gap-2.5`)}>
{icon ? icon : variantDefinition.icon}
{typeof children === "string" ? (
<Paragraph variant={"small"} className={variantDefinition.textColor}>
{children}
</Paragraph>
) : (
<span>{children}</span>
)}
</div>
{cta && cta}
</div>
);
}
@@ -0,0 +1,199 @@
import * as React from "react";
import { forwardRef, useEffect, useState } from "react";
import { cn } from "~/utils/cn";
import { Badge } from "./Badge";
import { Paragraph } from "./Paragraph";
const variants = {
"simple/small": {
button: "w-fit pr-4",
label: "text-sm text-text-bright mt-0.5 select-none",
description: "text-text-dimmed",
inputPosition: "mt-1",
isChecked: "",
isDisabled: "opacity-70",
},
simple: {
button: "w-fit pr-4",
label: "text-text-bright select-none",
description: "text-text-dimmed",
inputPosition: "mt-1",
isChecked: "",
isDisabled: "opacity-70",
},
"button/small": {
button:
"flex items-center w-fit h-8 pl-2 pr-3 rounded border border-border-bright hover:bg-background-dimmed hover:border-border-brightest transition",
label: "text-sm text-text-bright select-none",
description: "text-text-dimmed",
inputPosition: "mt-0",
isChecked: "bg-background-dimmed border-grid-dimmed hover:bg-background-dimmed!",
isDisabled: "opacity-70 hover:bg-transparent",
},
button: {
button:
"w-fit py-2 pl-3 pr-4 rounded border border-border-bright hover:bg-background-dimmed hover:border-border-brightest transition",
label: "text-text-bright select-none",
description: "text-text-dimmed",
inputPosition: "mt-1",
isChecked: "bg-background-dimmed border-grid-dimmed hover:bg-background-dimmed!",
isDisabled: "opacity-70 hover:bg-transparent",
},
description: {
button: "w-full py-2 pl-3 pr-4 checked:hover:bg-background-dimmed transition",
label: "text-text-bright font-semibold",
description: "text-text-dimmed",
inputPosition: "mt-1",
isChecked: "bg-background-dimmed",
isDisabled: "opacity-70",
},
};
export type CheckboxProps = Omit<
React.InputHTMLAttributes<HTMLInputElement>,
"checked" | "onChange"
> & {
id?: string;
name?: string;
value?: string;
variant?: keyof typeof variants;
label: React.ReactNode;
description?: string;
badges?: string[];
className?: string;
labelClassName?: string;
onChange?: (isChecked: boolean) => void;
};
export const CheckboxWithLabel = React.forwardRef<HTMLInputElement, CheckboxProps>(
(
{
id,
name,
value,
variant = "simple",
type,
label,
description,
defaultChecked,
badges,
disabled,
className,
labelClassName: externalLabelClassName,
...props
},
ref
) => {
const [isChecked, setIsChecked] = useState<boolean>(defaultChecked ?? false);
const [isDisabled, setIsDisabled] = useState<boolean>(disabled ?? false);
const buttonClassName = variants[variant].button;
const labelClassName = variants[variant].label;
const descriptionClassName = variants[variant].description;
const isCheckedClassName = variants[variant].isChecked;
const isDisabledClassName = variants[variant].isDisabled;
const inputPositionClasses = variants[variant].inputPosition;
useEffect(() => {
setIsDisabled(disabled ?? false);
}, [disabled]);
useEffect(() => {
if (props.onChange) {
props.onChange(isChecked);
}
}, [isChecked]);
useEffect(() => {
setIsChecked(defaultChecked ?? false);
}, [defaultChecked]);
return (
<div
className={cn(
"group flex items-start gap-x-2 transition ",
props.readOnly || disabled ? "cursor-default" : "cursor-pointer",
buttonClassName,
isChecked && isCheckedClassName,
(isDisabled || props.readOnly) && isDisabledClassName,
className
)}
onClick={(e) => {
//returning false is not setting the state to false, it stops the event from bubbling up
if (isDisabled || props.readOnly === true) return false;
setIsChecked((c) => !c);
}}
>
<input
{...props}
name={name}
type="checkbox"
value={value}
checked={isChecked}
onChange={(e) => {
//returning false is not setting the state to false, it stops the event from bubbling up
if (isDisabled || props.readOnly === true) return false;
setIsChecked(!isChecked);
}}
disabled={isDisabled}
className={cn(
inputPositionClasses,
props.readOnly || disabled ? "cursor-default" : "cursor-pointer",
"read-only:border-border-bright disabled:border-border-bright rounded-sm border border-border-bright bg-transparent transition checked:bg-indigo-500! read-only:bg-background-raised! group-hover:bg-background-deep checked:group-hover:bg-indigo-500 group-focus:ring-1 focus:ring-indigo-500 focus:ring-offset-0 focus:ring-offset-transparent focus-visible:outline-hidden focus-visible:ring-indigo-500 disabled:bg-background-raised!"
)}
id={id}
ref={ref}
/>
<div>
<div className="flex items-center gap-x-2">
<label
htmlFor={id}
className={cn(
props.readOnly || disabled ? "cursor-default" : "cursor-pointer",
labelClassName,
externalLabelClassName
)}
onClick={(e) => e.preventDefault()}
>
{label}
</label>
{badges && (
<span className="-mr-2 flex gap-x-1.5">
{badges.map((badge) => (
<Badge key={badge}>{badge}</Badge>
))}
</span>
)}
</div>
{variant === "description" && (
<Paragraph variant="small" className={cn("mt-0.5", descriptionClassName)}>
{description}
</Paragraph>
)}
</div>
</div>
);
}
);
type SimpleCheckboxProps = Omit<React.ComponentProps<"input">, "type">;
export const Checkbox = forwardRef<HTMLInputElement, SimpleCheckboxProps>(
({ className, ...props }: SimpleCheckboxProps, ref) => {
return (
<input
type="checkbox"
className={cn(
props.disabled
? "cursor-not-allowed"
: props.readOnly
? "cursor-default"
: "cursor-pointer",
"read-only:border-border-bright disabled:border-border-bright disabled:opacity-50 rounded-sm border border-border-bright bg-transparent transition checked:bg-indigo-500! read-only:bg-background-raised! group-hover:bg-background-deep checked:group-hover:bg-indigo-500 group-focus:ring-1 focus:ring-indigo-500 focus:ring-offset-0 focus:ring-offset-transparent focus-visible:outline-hidden focus-visible:ring-indigo-500 disabled:bg-background-raised!"
)}
{...props}
ref={ref}
/>
);
}
);
@@ -0,0 +1,24 @@
import { cn } from "~/utils/cn";
export function CheckboxIndicator({ checked }: { checked: boolean }) {
return (
<div
className={cn(
"flex size-4 flex-none items-center justify-center rounded border",
checked ? "border-indigo-500 bg-indigo-600" : "border-border-bright bg-background-raised"
)}
>
{checked && (
<svg className="size-3 text-white" viewBox="0 0 12 12" fill="none">
<path
d="M2.5 6L5 8.5L9.5 3.5"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)}
</div>
);
}
@@ -0,0 +1,213 @@
"use client";
import { motion } from "framer-motion";
import * as TabsPrimitive from "@radix-ui/react-tabs";
import * as React from "react";
import { cn } from "~/utils/cn";
import { type Variants } from "./Tabs";
type ClientTabsContextValue = {
value?: string;
};
const ClientTabsContext = React.createContext<ClientTabsContextValue | undefined>(undefined);
function useClientTabsContext() {
return React.useContext(ClientTabsContext);
}
const ClientTabs = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Root>
>(({ onValueChange, value: valueProp, defaultValue, ...props }, ref) => {
const [value, setValue] = React.useState<string | undefined>(valueProp ?? defaultValue);
React.useEffect(() => {
if (valueProp !== undefined) {
setValue(valueProp);
}
}, [valueProp]);
const handleValueChange = React.useCallback(
(nextValue: string) => {
if (valueProp === undefined) {
setValue(nextValue);
}
onValueChange?.(nextValue);
},
[onValueChange, valueProp]
);
const controlledProps =
valueProp !== undefined
? { value: valueProp }
: defaultValue !== undefined
? { defaultValue }
: {};
const contextValue = React.useMemo<ClientTabsContextValue>(() => ({ value }), [value]);
return (
<ClientTabsContext.Provider value={contextValue}>
<TabsPrimitive.Root
ref={ref}
activationMode="manual"
onValueChange={handleValueChange}
{...controlledProps}
{...props}
/>
</ClientTabsContext.Provider>
);
});
ClientTabs.displayName = TabsPrimitive.Root.displayName;
const ClientTabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List> & {
variant?: Variants;
}
>(({ className, variant = "pipe-divider", ...props }, ref) => {
const variantClassName = (() => {
switch (variant) {
case "segmented":
return "relative flex h-10 w-full items-center rounded bg-background-raised/50 p-1";
case "underline":
return "flex gap-x-6 border-b border-grid-bright";
default:
return "inline-flex items-center justify-center transition duration-100";
}
})();
return <TabsPrimitive.List ref={ref} className={cn(variantClassName, className)} {...props} />;
});
ClientTabsList.displayName = TabsPrimitive.List.displayName;
const ClientTabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger> & {
variant?: Variants;
layoutId?: string;
}
>(({ className, variant = "pipe-divider", layoutId, children, ...props }, ref) => {
const context = useClientTabsContext();
const activeValue = context?.value;
const isActive = activeValue === props.value;
if (variant === "segmented") {
return (
<TabsPrimitive.Trigger
ref={ref}
tabIndex={0}
className={cn(
"group relative flex h-full grow items-center justify-center focus-custom disabled:pointer-events-none disabled:opacity-50",
"flex-1 basis-0",
className
)}
{...props}
>
<div className="relative z-10 flex h-full w-full items-center justify-center px-3 py-[0.13rem]">
<span
className={cn(
"text-sm transition duration-200",
isActive
? "text-text-bright"
: "text-text-dimmed transition group-hover:text-text-bright"
)}
>
{children}
</span>
</div>
{isActive ? (
layoutId ? (
<motion.div
layoutId={layoutId}
transition={{ duration: 0.4, type: "spring" }}
className="absolute inset-0 rounded-[2px] border border-border-brightest/50 bg-surface-control"
/>
) : (
<div className="absolute inset-0 rounded-[2px] border border-border-brightest/50 bg-surface-control" />
)
) : null}
</TabsPrimitive.Trigger>
);
}
if (variant === "underline") {
return (
<TabsPrimitive.Trigger
ref={ref}
tabIndex={0}
className={cn(
"group flex flex-col items-center pt-1 focus-custom disabled:pointer-events-none disabled:opacity-50",
className
)}
{...props}
>
<span
className={cn(
"text-sm transition duration-200",
isActive ? "text-text-bright" : "text-text-dimmed group-hover:text-text-bright"
)}
>
{children}
</span>
{layoutId ? (
isActive ? (
<motion.div
layoutId={layoutId}
transition={{ type: "spring", stiffness: 500, damping: 30 }}
className="mt-1 h-0.5 w-full bg-indigo-500"
/>
) : (
<div className="mt-1 h-0.5 w-full bg-surface-control-active opacity-0 transition duration-200 group-hover:opacity-100" />
)
) : isActive ? (
<div className="mt-1 h-0.5 w-full bg-indigo-500" />
) : (
<div className="mt-1 h-0.5 w-full bg-surface-control-active opacity-0 transition duration-200 group-hover:opacity-100" />
)}
</TabsPrimitive.Trigger>
);
}
return (
<TabsPrimitive.Trigger
ref={ref}
tabIndex={0}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap border-r border-grid-bright px-2 text-sm transition-all first:pl-0 last:border-none focus-custom data-[state=active]:text-indigo-500 data-[state=inactive]:text-text-dimmed data-[state=inactive]:hover:text-text-bright disabled:pointer-events-none disabled:opacity-50",
className
)}
{...props}
>
{children}
</TabsPrimitive.Trigger>
);
});
ClientTabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
const ClientTabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
tabIndex={-1}
className={cn("mt-1 outline-hidden", className, "data-[state=inactive]:hidden")}
{...props}
/>
));
ClientTabsContent.displayName = TabsPrimitive.Content.displayName;
export type TabsProps = {
tabs: {
label: string;
value: string;
}[];
currentValue: string;
className?: string;
layoutId: string;
variant?: Variants;
};
export { ClientTabs, ClientTabsContent, ClientTabsList, ClientTabsTrigger };
@@ -0,0 +1,132 @@
import { useEffect, useRef, useState } from "react";
import { cn } from "~/utils/cn";
import { CopyButton } from "./CopyButton";
const variants = {
"primary/small": {
container:
"flex items-center text-text-dimmed font-mono rounded border bg-background-hover text-xs transition hover:bg-background-raised focus-visible:outline-hidden focus-visible:ring-0 focus-visible:ring-offset-0 focus:border-transparent focus:outline-hidden focus:ring-0 focus:ring-transparent",
input:
"bg-transparent border-0 text-xs px-2 w-auto rounded-l h-6 leading-6 focus:ring-transparent",
buttonVariant: "primary" as const,
size: "small" as const,
button: "rounded-l-none",
},
"secondary/small": {
container:
"flex items-center text-text-dimmed font-mono rounded border bg-background-hover text-xs transition hover:bg-background-raised focus-visible:outline-hidden focus-visible:ring-0 focus-visible:ring-offset-0 focus:border-transparent focus:outline-hidden focus:ring-0 focus:ring-transparent",
input:
"bg-transparent border-0 text-xs px-2 w-auto rounded-l h-6 leading-6 focus:ring-transparent",
buttonVariant: "tertiary" as const,
size: "small" as const,
button: "rounded-l-none border-l border-grid-dimmed",
},
"tertiary/small": {
container:
"group/clipboard flex items-center text-text-dimmed font-mono rounded bg-transparent border border-transparent text-xs transition duration-150 hover:border-grid-bright focus-visible:outline-hidden focus-visible:ring-0 focus-visible:ring-offset-0 focus:border-transparent focus:outline-hidden focus:ring-0 focus:ring-transparent",
input:
"bg-transparent border-0 text-xs px-2 w-auto rounded-l h-6 leading-6 focus:ring-transparent",
buttonVariant: "minimal" as const,
size: "small" as const,
button:
"rounded-l-none border-l border-transparent transition group-hover/clipboard:border-grid-bright",
},
"primary/medium": {
container:
"flex items-center text-text-dimmed font-mono rounded border bg-background-hover text-sm transition hover:bg-background-raised focus-visible:outline-hidden focus-visible:ring-0 focus-visible:ring-offset-0 focus:border-transparent focus:outline-hidden focus:ring-0 focus:ring-transparent",
input:
"bg-transparent border-0 text-sm px-3 w-auto rounded-l h-8 leading-6 focus:ring-transparent",
buttonVariant: "primary" as const,
size: "medium" as const,
button: "rounded-l-none",
},
"secondary/medium": {
container:
"flex items-center text-text-dimmed font-mono rounded bg-background-hover text-sm transition hover:bg-background-raised focus-visible:outline-hidden focus-visible:ring-0 focus-visible:ring-offset-0 focus:border-transparent focus:outline-hidden focus:ring-0 focus:ring-transparent",
input:
"bg-transparent border-0 text-sm px-3 w-auto rounded-l h-8 leading-6 focus:ring-transparent",
buttonVariant: "tertiary" as const,
size: "medium" as const,
button: "rounded-l-none border-l border-grid-dimmed",
},
"tertiary/medium": {
container:
"group flex items-center text-text-dimmed font-mono rounded bg-transparent border border-transparent text-sm transition hover:border-grid-bright focus-visible:outline-hidden focus-visible:ring-0 focus-visible:ring-offset-0 focus:border-transparent focus:outline-hidden focus:ring-0 focus:ring-transparent",
input:
"bg-transparent border-0 text-sm px-3 w-auto rounded-l h-8 leading-6 focus:ring-transparent",
buttonVariant: "minimal" as const,
size: "medium" as const,
button: "rounded-l-none border-l border-transparent transition group-hover:border-grid-bright",
},
};
type ClipboardFieldProps = {
value: string;
secure?: boolean | string;
variant: keyof typeof variants;
className?: string;
icon?: React.ReactNode;
iconButton?: boolean;
fullWidth?: boolean;
};
export function ClipboardField({
value,
secure = false,
variant,
className,
icon,
iconButton = false,
fullWidth = true,
}: ClipboardFieldProps) {
const [isSecure, setIsSecure] = useState(secure !== undefined && secure);
const inputIcon = useRef<HTMLInputElement>(null);
const { container, input, buttonVariant, button, size } = variants[variant];
useEffect(() => {
setIsSecure(secure !== undefined && secure);
}, [secure]);
return (
<span className={cn(container, fullWidth ? "w-full" : "max-w-fit", className)}>
{icon && (
<span
onClick={() => inputIcon.current && inputIcon.current.focus()}
className="flex items-center pl-1"
>
{icon}
</span>
)}
<input
type="text"
ref={inputIcon}
value={isSecure ? (typeof secure === "string" ? secure : "••••••••••••••••") : value}
readOnly={true}
className={cn(
"shrink grow select-all overflow-x-auto",
fullWidth ? "w-full" : "max-w-fit",
input
)}
onFocus={(e) => {
if (secure) {
setIsSecure(false);
}
e.currentTarget.select();
}}
onBlur={() => {
if (secure) {
setIsSecure(true);
}
}}
/>
<CopyButton
value={value}
variant={iconButton ? "icon" : "button"}
buttonVariant={buttonVariant}
size={size}
buttonClassName={button}
showTooltip={false}
/>
</span>
);
}
@@ -0,0 +1,105 @@
import { ClipboardCheckIcon, ClipboardIcon } from "lucide-react";
import { useCopy } from "~/hooks/useCopy";
import { cn } from "~/utils/cn";
import { Button } from "./Buttons";
import { SimpleTooltip } from "./Tooltip";
const sizes = {
"extra-small": {
icon: "size-3",
button: "h-5 px-1",
},
small: {
icon: "size-3.5",
button: "h-6 px-1",
},
medium: {
icon: "size-4",
button: "h-8 px-1.5",
},
};
type CopyButtonProps = {
value: string;
variant?: "icon" | "button";
size?: keyof typeof sizes;
className?: string;
buttonClassName?: string;
showTooltip?: boolean;
buttonVariant?: "primary" | "secondary" | "tertiary" | "minimal";
children?: React.ReactNode;
};
export function CopyButton({
value,
variant = "button",
size = "medium",
className,
buttonClassName,
showTooltip = true,
buttonVariant = "tertiary",
children,
}: CopyButtonProps) {
const { copy, copied } = useCopy(value);
const { icon: iconSize, button: buttonSize } = sizes[size];
const button =
variant === "icon" ? (
<span
onClick={copy}
className={cn(
buttonSize,
"flex items-center justify-center rounded border border-border-bright bg-background-hover",
copied
? "text-green-500"
: "text-text-dimmed hover:border-border-bright hover:bg-background-raised hover:text-text-bright",
buttonClassName
)}
>
{copied ? (
<ClipboardCheckIcon className={iconSize} />
) : (
<ClipboardIcon className={iconSize} />
)}
</span>
) : (
<Button
variant={`${buttonVariant}/${size === "extra-small" ? "small" : size}`}
onClick={copy}
className={cn("shrink-0", buttonClassName)}
LeadingIcon={
copied ? (
<ClipboardCheckIcon
className={cn(
iconSize,
buttonVariant === "primary" ? "text-background-dimmed" : "text-green-500"
)}
/>
) : (
<ClipboardIcon
className={cn(
iconSize,
buttonVariant === "primary" ? "text-background-dimmed" : "text-text-dimmed"
)}
/>
)
}
>
{children}
</Button>
);
if (!showTooltip) return <span className={className}>{button}</span>;
return (
<span className={className}>
<SimpleTooltip
button={button}
content={copied ? "Copied!" : "Copy"}
className="font-sans"
disableHoverableContent
/>
</span>
);
}
@@ -0,0 +1,27 @@
import { ClipboardCheckIcon, ClipboardIcon } from "lucide-react";
import { useCopy } from "~/hooks/useCopy";
import { cn } from "~/utils/cn";
type CopyTextLinkProps = {
value: string;
className?: string;
};
export function CopyTextLink({ value, className }: CopyTextLinkProps) {
const { copy, copied } = useCopy(value);
return (
<button
type="button"
onClick={copy}
className={cn(
"inline-flex cursor-pointer items-center gap-1 text-xs transition-colors",
copied ? "text-success" : "text-text-dimmed hover:text-text-bright",
className
)}
>
{copied ? "Copied" : "Copy"}
{copied ? <ClipboardCheckIcon className="size-3" /> : <ClipboardIcon className="size-3" />}
</button>
);
}
@@ -0,0 +1,98 @@
import { ClipboardCheckIcon, ClipboardIcon } from "lucide-react";
import { useState } from "react";
import { SimpleTooltip } from "~/components/primitives/Tooltip";
import { useCopy } from "~/hooks/useCopy";
import { cn } from "~/utils/cn";
import { Button } from "./Buttons";
export function CopyableText({
value,
copyValue,
className,
asChild,
variant,
}: {
value: string;
copyValue?: string;
className?: string;
asChild?: boolean;
variant?: "icon-right" | "text-below";
}) {
const [isHovered, setIsHovered] = useState(false);
const { copy, copied } = useCopy(copyValue ?? value);
const resolvedVariant = variant ?? "icon-right";
if (resolvedVariant === "icon-right") {
return (
<span
className={cn("group relative inline-flex h-6 items-center", className)}
onMouseLeave={() => setIsHovered(false)}
>
<span onMouseEnter={() => setIsHovered(true)}>{value}</span>
<span
onClick={copy}
onMouseDown={(e) => e.stopPropagation()}
className={cn(
"absolute -right-6 top-0 z-10 size-6 font-sans",
isHovered ? "flex" : "hidden"
)}
>
<SimpleTooltip
button={
<span
className={cn(
"ml-1 flex size-6 items-center justify-center rounded border border-border-bright bg-background-hover",
asChild && "p-1",
copied
? "text-green-500"
: "text-text-dimmed hover:border-border-bright hover:bg-background-raised hover:text-text-bright"
)}
>
{copied ? (
<ClipboardCheckIcon className="size-3.5" />
) : (
<ClipboardIcon className="size-3.5" />
)}
</span>
}
content={copied ? "Copied!" : "Copy"}
className="font-sans"
disableHoverableContent
asChild={asChild}
/>
</span>
</span>
);
}
if (resolvedVariant === "text-below") {
return (
<SimpleTooltip
button={
<Button
variant="minimal/small"
onClick={(e) => {
e.stopPropagation();
copy();
}}
className={cn(
"cursor-pointer bg-transparent px-1 py-0 text-left text-text-dimmed transition-colors hover:bg-transparent",
className
)}
>
<span className="transition-colors group-hover/button:text-text-bright">{value}</span>
</Button>
}
content={copied ? "Copied" : "Copy"}
className="px-2 py-1 font-sans"
disableHoverableContent
open={isHovered || copied}
onOpenChange={setIsHovered}
asChild
/>
);
}
return null;
}
@@ -0,0 +1,287 @@
import { CalendarDateTime, createCalendar } from "@internationalized/date";
import { useDateField, useDateSegment } from "@react-aria/datepicker";
import {
useDateFieldState,
type DateFieldState,
type DateSegment,
} from "@react-stately/datepicker";
import { type Granularity } from "@react-types/datepicker";
import { useEffect, useRef, useState } from "react";
import { cn } from "~/utils/cn";
import { Button } from "./Buttons";
const variants = {
small: {
fieldStyles: "h-5 text-xs rounded-sm px-0.5",
nowButtonVariant: "tertiary/small" as const,
clearButtonVariant: "tertiary/small" as const,
},
medium: {
fieldStyles: "h-7 text-sm rounded px-1",
nowButtonVariant: "tertiary/medium" as const,
clearButtonVariant: "minimal/medium" as const,
},
};
type Variant = keyof typeof variants;
type DateFieldProps = {
label: string;
defaultValue?: Date;
minValue?: Date;
maxValue?: Date;
className?: string;
fieldClassName?: string;
granularity: Granularity;
showGuide?: boolean;
showNowButton?: boolean;
showClearButton?: boolean;
onValueChange?: (value: Date | undefined) => void;
utc?: boolean;
variant?: Variant;
};
const deviceTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
export function DateField({
label,
defaultValue,
onValueChange,
minValue,
maxValue,
granularity,
className,
fieldClassName,
showGuide = false,
showNowButton = false,
showClearButton = false,
utc = false,
variant = "small",
}: DateFieldProps) {
const [value, setValue] = useState<undefined | CalendarDateTime>(
utc ? utcDateToCalendarDate(defaultValue) : dateToCalendarDate(defaultValue)
);
const state = useDateFieldState({
value: value,
onChange: (value) => {
if (value) {
setValue(value);
onValueChange?.(value.toDate(utc ? "utc" : deviceTimezone));
}
},
minValue: utc ? utcDateToCalendarDate(minValue) : dateToCalendarDate(minValue),
maxValue: utc ? utcDateToCalendarDate(maxValue) : dateToCalendarDate(maxValue),
shouldForceLeadingZeros: true,
granularity,
locale: "en-US",
createCalendar: (name: string) => {
return createCalendar(name);
},
});
//if the passed in value changes, we should update the date
useEffect(() => {
if (state.value === undefined && defaultValue === undefined) return;
const calendarDate = utc
? utcDateToCalendarDate(defaultValue)
: dateToCalendarDate(defaultValue);
//unchanged
if (state.value?.toDate("utc").getTime() === defaultValue?.getTime()) {
return;
}
setValue(calendarDate);
}, [defaultValue]);
const ref = useRef<null | HTMLDivElement>(null);
const { labelProps: _labelProps, fieldProps } = useDateField(
{
label,
},
state,
ref
);
//render if reverse date order
const yearSegment = state.segments.find((s) => s.type === "year")!;
const monthSegment = state.segments.find((s) => s.type === "month")!;
const daySegment = state.segments.find((s) => s.type === "day")!;
const hourSegment = state.segments.find((s) => s.type === "hour")!;
const minuteSegment = state.segments.find((s) => s.type === "minute")!;
const secondSegment = state.segments.find((s) => s.type === "second")!;
const dayPeriodSegment = state.segments.find((s) => s.type === "dayPeriod")!;
return (
<div className={`flex flex-col items-start ${className || ""}`}>
<div className="flex flex-row items-center gap-1" aria-label={label}>
<div
{...fieldProps}
ref={ref}
className={cn(
"flex rounded-sm border bg-background-raised p-0.5 transition focus-within:border-border-bright hover:border-border-bright",
fieldClassName
)}
>
<DateSegment segment={yearSegment} state={state} variant={variant} />
<DateSegment segment={literalSegment("/")} state={state} variant={variant} />
<DateSegment segment={monthSegment} state={state} variant={variant} />
<DateSegment segment={literalSegment("/")} state={state} variant={variant} />
<DateSegment segment={daySegment} state={state} variant={variant} />
<DateSegment segment={literalSegment(", ")} state={state} variant={variant} />
<DateSegment segment={hourSegment} state={state} variant={variant} />
<DateSegment segment={literalSegment(":")} state={state} variant={variant} />
<DateSegment segment={minuteSegment} state={state} variant={variant} />
<DateSegment segment={literalSegment(":")} state={state} variant={variant} />
<DateSegment segment={secondSegment} state={state} variant={variant} />
<DateSegment segment={literalSegment(" ")} state={state} variant={variant} />
<DateSegment segment={dayPeriodSegment} state={state} variant={variant} />
</div>
{showNowButton && (
<Button
type="button"
variant={variants[variant].nowButtonVariant}
onClick={() => {
const now = new Date();
setValue(utc ? utcDateToCalendarDate(now) : dateToCalendarDate(now));
onValueChange?.(now);
}}
>
Now
</Button>
)}
{showClearButton && (
<Button
type="button"
variant={variants[variant].clearButtonVariant}
onClick={() => {
setValue(undefined);
onValueChange?.(undefined);
state.clearSegment("year");
state.clearSegment("month");
state.clearSegment("day");
state.clearSegment("hour");
state.clearSegment("minute");
state.clearSegment("second");
}}
>
Clear
</Button>
)}
</div>
{showGuide && (
<div className="mt-1 flex px-2">
{state.segments.map((segment, i) => (
<DateSegmentGuide key={i} segment={segment} />
))}
</div>
)}
</div>
);
}
function utcDateToCalendarDate(date?: Date) {
return date
? new CalendarDateTime(
date.getUTCFullYear(),
date.getUTCMonth() + 1,
date.getUTCDate(),
date.getUTCHours(),
date.getUTCMinutes(),
date.getUTCSeconds()
)
: undefined;
}
function dateToCalendarDate(date?: Date) {
return date
? new CalendarDateTime(
date.getFullYear(),
date.getMonth() + 1,
date.getDate(),
date.getHours(),
date.getMinutes(),
date.getSeconds()
)
: undefined;
}
type DateSegmentProps = {
segment: DateSegment;
state: DateFieldState;
variant: Variant;
};
function DateSegment({ segment, state, variant }: DateSegmentProps) {
const ref = useRef<null | HTMLDivElement>(null);
const { segmentProps } = useDateSegment(segment, state, ref);
const sizeVariant = variants[variant];
return (
<div
{...segmentProps}
ref={ref}
style={{
...segmentProps.style,
minWidth: minWidthForSegment(segment),
}}
className={cn(
"group box-content text-center tabular-nums outline-hidden focus:bg-surface-control focus:text-text-bright",
sizeVariant.fieldStyles,
!segment.isEditable ? "text-text-faint" : "text-text-bright"
)}
>
{/* Always reserve space for the placeholder, to prevent layout shift when editing. */}
<span
aria-hidden="true"
className="flex h-full items-center justify-center text-center text-text-faint group-focus:text-text-bright"
style={{
visibility: segment.isPlaceholder ? undefined : "hidden",
height: segment.isPlaceholder ? undefined : 0,
pointerEvents: "none",
}}
>
{segment.placeholder}
</span>
<span className="flex h-full items-center justify-center">
{segment.isPlaceholder ? "" : segment.text}
</span>
</div>
);
}
function literalSegment(text: string): DateSegment {
return {
type: "literal",
text,
isPlaceholder: false,
isEditable: false,
placeholder: "",
};
}
function minWidthForSegment(segment: DateSegment) {
if (segment.type === "literal") {
return undefined;
}
return String(`${segment.maxValue}`).length + "ch";
}
function DateSegmentGuide({ segment }: { segment: DateSegment }) {
return (
<div
style={{
minWidth: minWidthForSegment(segment),
}}
className={`group box-content rounded-sm px-0.5 text-right text-sm tabular-nums text-rose-500 outline-hidden ${
!segment.isEditable ? "text-text-faint" : "text-text-bright"
}`}
>
<span className="block text-center italic text-text-faint">
{segment.type !== "literal" ? segment.placeholder : segment.text}
</span>
</div>
);
}
@@ -0,0 +1,538 @@
import { GlobeAltIcon, GlobeAmericasIcon } from "@heroicons/react/20/solid";
import { useRouteLoaderData } from "@remix-run/react";
import { formatDistanceToNow } from "date-fns";
import { Laptop } from "lucide-react";
import { memo, type ReactNode, useEffect, useMemo, useState, useSyncExternalStore } from "react";
import { CopyButton } from "./CopyButton";
import { useLocales } from "./LocaleProvider";
import { Paragraph } from "./Paragraph";
import { SimpleTooltip } from "./Tooltip";
// Cache the browser's local timezone - resolved once and reused
let cachedLocalTimeZone: string | null = null;
function getLocalTimeZone(): string {
if (cachedLocalTimeZone === null) {
cachedLocalTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
}
return cachedLocalTimeZone;
}
// For SSR compatibility: returns "UTC" on server, actual timezone on client
function subscribeToTimeZone() {
// No-op - timezone doesn't change
return () => {};
}
function getTimeZoneSnapshot(): string {
return getLocalTimeZone();
}
function getServerTimeZoneSnapshot(): string {
return "UTC";
}
/**
* Hook to get the browser's local timezone.
* Uses useSyncExternalStore for SSR compatibility - returns "UTC" on server,
* actual timezone on client. The timezone is cached and only resolved once.
*/
export function useLocalTimeZone(): string {
return useSyncExternalStore(subscribeToTimeZone, getTimeZoneSnapshot, getServerTimeZoneSnapshot);
}
/**
* Hook to get the user's preferred timezone.
* Returns the timezone stored in the user's preferences cookie (from root loader),
* falling back to the browser's local timezone if not set.
*/
export function useUserTimeZone(): string {
const rootData = useRouteLoaderData("root") as { timezone?: string } | undefined;
const localTimeZone = useLocalTimeZone();
// Use stored timezone from cookie, or fall back to browser's local timezone
return rootData?.timezone && rootData.timezone !== "UTC" ? rootData.timezone : localTimeZone;
}
type DateTimeProps = {
date: Date | string;
timeZone?: string;
includeSeconds?: boolean;
includeTime?: boolean;
includeDate?: boolean;
showTimezone?: boolean;
showTooltip?: boolean;
hideDate?: boolean;
previousDate?: Date | string | null; // Add optional previous date for comparison
hour12?: boolean;
};
export const DateTime = ({
date,
timeZone,
includeSeconds = true,
includeTime = true,
includeDate = true,
showTimezone = false,
showTooltip = true,
hour12 = true,
}: DateTimeProps) => {
const locales = useLocales();
const userTimeZone = useUserTimeZone();
const realDate = useMemo(() => (typeof date === "string" ? new Date(date) : date), [date]);
const formattedDateTime = (
<span suppressHydrationWarning>
{formatDateTime(
realDate,
timeZone ?? userTimeZone,
locales,
includeSeconds,
includeTime,
includeDate,
hour12
).replace(/\s/g, String.fromCharCode(32))}
{showTimezone ? ` (${timeZone ?? "UTC"})` : null}
</span>
);
if (!showTooltip) return formattedDateTime;
return (
<SimpleTooltip
button={formattedDateTime}
content={
<TooltipContent
realDate={realDate}
timeZone={timeZone}
localTimeZone={userTimeZone}
locales={locales}
/>
}
side="right"
asChild={true}
/>
);
};
export function formatDateTime(
date: Date,
timeZone: string,
locales: string[],
includeSeconds: boolean,
includeTime: boolean,
includeDate: boolean = true,
hour12: boolean = true
): string {
return new Intl.DateTimeFormat(locales, {
year: includeDate ? "numeric" : undefined,
month: includeDate ? "short" : undefined,
day: includeDate ? "numeric" : undefined,
hour: includeTime ? "numeric" : undefined,
minute: includeTime ? "numeric" : undefined,
second: includeTime && includeSeconds ? "numeric" : undefined,
timeZone,
hour12,
}).format(date);
}
export function formatDateTimeISO(date: Date, timeZone: string): string {
// Special handling for UTC
if (timeZone === "UTC") {
return date.toISOString();
}
// Get the date parts in the target timezone
const dateFormatter = new Intl.DateTimeFormat("en-US", {
timeZone,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
});
// Get the timezone offset for this specific date
const timeZoneFormatter = new Intl.DateTimeFormat("en-US", {
timeZone,
timeZoneName: "longOffset",
});
const dateParts = Object.fromEntries(
dateFormatter.formatToParts(date).map(({ type, value }) => [type, value])
);
const timeZoneParts = timeZoneFormatter.formatToParts(date);
const offset =
timeZoneParts.find((part) => part.type === "timeZoneName")?.value.replace("GMT", "") ||
"+00:00";
// Format: YYYY-MM-DDThh:mm:ss.sss±hh:mm
return (
`${dateParts.year}-${dateParts.month}-${dateParts.day}T` +
`${dateParts.hour}:${dateParts.minute}:${dateParts.second}.${String(
date.getMilliseconds()
).padStart(3, "0")}${offset}`
);
}
/**
* Human-readable UTC offset for a timezone at a specific instant, e.g. "(UTC +3)".
* Returns "" for UTC. The offset must be derived from the same (date, timeZone) pair
* used to render the displayed time so the label always matches the value shown — and
* so it stays correct across DST boundaries regardless of the viewer's current season.
*/
export function formatUtcOffset(date: Date, timeZone: string): string {
if (timeZone === "UTC") return "";
const parts = new Intl.DateTimeFormat("en-US", {
timeZone,
timeZoneName: "longOffset",
}).formatToParts(date);
// longOffset yields "GMT+03:00", "GMT-08:00", "GMT+05:30", or "GMT" for UTC-equivalent zones.
const raw = parts.find((part) => part.type === "timeZoneName")?.value.replace("GMT", "") ?? "";
const match = raw.match(/^([+-])(\d{2}):(\d{2})$/);
if (!match) return "(UTC +0)";
const [, sign, hh, mm] = match;
const hours = parseInt(hh, 10);
const minutes = parseInt(mm, 10);
return `(UTC ${sign}${hours}${minutes ? `:${minutes.toString().padStart(2, "0")}` : ""})`;
}
// New component that only shows date when it changes
export const SmartDateTime = ({ date, previousDate = null, hour12 = true }: DateTimeProps) => {
const locales = useLocales();
const userTimeZone = useUserTimeZone();
const realDate = typeof date === "string" ? new Date(date) : date;
const realPrevDate = previousDate
? typeof previousDate === "string"
? new Date(previousDate)
: previousDate
: null;
// Check if we should show the date
const showDatePart = !realPrevDate || !isSameDay(realDate, realPrevDate);
// Format with appropriate function
const formattedDateTime = showDatePart
? formatSmartDateTime(realDate, userTimeZone, locales, hour12)
: formatTimeOnly(realDate, userTimeZone, locales, hour12);
return (
<span suppressHydrationWarning>
{formattedDateTime.replace(/\s/g, String.fromCharCode(32))}
</span>
);
};
// Helper function to check if two dates are on the same day
function isSameDay(date1: Date, date2: Date): boolean {
return (
date1.getFullYear() === date2.getFullYear() &&
date1.getMonth() === date2.getMonth() &&
date1.getDate() === date2.getDate()
);
}
// Format with date and time
function formatSmartDateTime(
date: Date,
timeZone: string,
locales: string[],
hour12: boolean = true
): string {
return new Intl.DateTimeFormat(locales, {
month: "short",
day: "numeric",
hour: "numeric",
minute: "numeric",
second: "numeric",
timeZone,
// @ts-ignore fractionalSecondDigits works in most modern browsers
fractionalSecondDigits: 3,
hour12,
}).format(date);
}
// Format time only
function formatTimeOnly(
date: Date,
timeZone: string,
locales: string[],
hour12: boolean = true
): string {
return new Intl.DateTimeFormat(locales, {
hour: "2-digit",
minute: "numeric",
second: "numeric",
timeZone,
// @ts-ignore fractionalSecondDigits works in most modern browsers
fractionalSecondDigits: 3,
hour12,
}).format(date);
}
const DateTimeAccurateInner = ({
date,
timeZone,
previousDate = null,
showTooltip = true,
hideDate = false,
hour12 = true,
}: DateTimeProps) => {
const locales = useLocales();
const userTimeZone = useUserTimeZone();
// Use provided timeZone prop if available, otherwise fall back to user's preferred timezone
const displayTimeZone = timeZone ?? userTimeZone;
const realDate = typeof date === "string" ? new Date(date) : date;
const realPrevDate = previousDate
? typeof previousDate === "string"
? new Date(previousDate)
: previousDate
: null;
// Smart formatting based on whether date changed
const formattedDateTime = useMemo(() => {
return hideDate
? formatTimeOnly(realDate, displayTimeZone, locales, hour12)
: realPrevDate
? isSameDay(realDate, realPrevDate)
? formatTimeOnly(realDate, displayTimeZone, locales, hour12)
: formatDateTimeAccurate(realDate, displayTimeZone, locales, hour12)
: formatDateTimeAccurate(realDate, displayTimeZone, locales, hour12);
}, [realDate, displayTimeZone, locales, hour12, hideDate, previousDate]);
if (!showTooltip)
return (
<span suppressHydrationWarning>
{formattedDateTime.replace(/\s/g, String.fromCharCode(32))}
</span>
);
const tooltipContent = (
<TooltipContent
realDate={realDate}
timeZone={timeZone}
localTimeZone={userTimeZone}
locales={locales}
/>
);
return (
<SimpleTooltip
button={
<span suppressHydrationWarning>
{formattedDateTime.replace(/\s/g, String.fromCharCode(32))}
</span>
}
content={tooltipContent}
side="right"
asChild={true}
/>
);
};
function areDateTimePropsEqual(prev: DateTimeProps, next: DateTimeProps): boolean {
// Compare Date objects by timestamp value, not reference
const prevTime = prev.date instanceof Date ? prev.date.getTime() : prev.date;
const nextTime = next.date instanceof Date ? next.date.getTime() : next.date;
if (prevTime !== nextTime) return false;
const prevPrevTime =
prev.previousDate instanceof Date ? prev.previousDate.getTime() : prev.previousDate;
const nextPrevTime =
next.previousDate instanceof Date ? next.previousDate.getTime() : next.previousDate;
if (prevPrevTime !== nextPrevTime) return false;
return (
prev.timeZone === next.timeZone &&
prev.showTooltip === next.showTooltip &&
prev.hideDate === next.hideDate &&
prev.hour12 === next.hour12
);
}
export const DateTimeAccurate = memo(DateTimeAccurateInner, areDateTimePropsEqual);
function formatDateTimeAccurate(
date: Date,
timeZone: string,
locales: string[],
hour12: boolean = true
): string {
const datePart = new Intl.DateTimeFormat(locales, {
month: "short",
day: "numeric",
timeZone,
}).format(date);
const timePart = new Intl.DateTimeFormat(locales, {
hour: "numeric",
minute: "numeric",
second: "numeric",
timeZone,
// @ts-ignore fractionalSecondDigits works in most modern browsers
fractionalSecondDigits: 3,
hour12,
}).format(date);
return `${datePart} ${timePart}`;
}
type RelativeDateTimeProps = {
date: Date | string;
timeZone?: string;
capitalize?: boolean;
};
function getRelativeText(date: Date, capitalize = true): string {
const text = formatDistanceToNow(date, { addSuffix: true });
if (!capitalize) return text;
return text.charAt(0).toUpperCase() + text.slice(1);
}
export const RelativeDateTime = ({ date, timeZone, capitalize = true }: RelativeDateTimeProps) => {
const locales = useLocales();
const userTimeZone = useUserTimeZone();
const realDate = useMemo(() => (typeof date === "string" ? new Date(date) : date), [date]);
const [relativeText, setRelativeText] = useState(() => getRelativeText(realDate, capitalize));
// Every 60s refresh
useEffect(() => {
const interval = setInterval(() => {
setRelativeText(getRelativeText(realDate, capitalize));
}, 60_000);
return () => clearInterval(interval);
}, [realDate, capitalize]);
// On first render
useEffect(() => {
setRelativeText(getRelativeText(realDate, capitalize));
}, [realDate, capitalize]);
return (
<SimpleTooltip
button={<span suppressHydrationWarning>{relativeText}</span>}
content={
<TooltipContent
realDate={realDate}
timeZone={timeZone}
localTimeZone={userTimeZone}
locales={locales}
/>
}
side="right"
asChild={true}
/>
);
};
export const DateTimeShort = ({ date, hour12 = true }: DateTimeProps) => {
const locales = useLocales();
const userTimeZone = useUserTimeZone();
const realDate = typeof date === "string" ? new Date(date) : date;
const formattedDateTime = formatDateTimeShort(realDate, userTimeZone, locales, hour12);
return (
<span suppressHydrationWarning>
{formattedDateTime.replace(/\s/g, String.fromCharCode(32))}
</span>
);
};
function formatDateTimeShort(
date: Date,
timeZone: string,
locales: string[],
hour12: boolean = true
): string {
const formattedDateTime = new Intl.DateTimeFormat(locales, {
hour: "numeric",
minute: "numeric",
second: "numeric",
timeZone,
// @ts-ignore fractionalSecondDigits works in most modern browsers
fractionalSecondDigits: 3,
hour12,
}).format(date);
return formattedDateTime;
}
type DateTimeTooltipContentProps = {
title: string;
dateTime: string;
isoDateTime: string;
icon: ReactNode;
offset?: string;
};
function DateTimeTooltipContent({
title,
dateTime,
isoDateTime,
icon,
offset,
}: DateTimeTooltipContentProps) {
return (
<div className="flex flex-col gap-1">
<div className="flex items-center gap-1 text-sm">
{icon}
<span className="font-medium">{title}</span>
{offset ? <span className="font-normal text-text-dimmed">{offset}</span> : null}
</div>
<div className="flex items-center justify-between gap-2">
<Paragraph variant="extra-small" className="text-text-dimmed">
{dateTime}
</Paragraph>
<CopyButton value={isoDateTime} variant="icon" size="extra-small" showTooltip={false} />
</div>
</div>
);
}
function TooltipContent({
realDate,
timeZone,
localTimeZone,
locales,
}: {
realDate: Date;
timeZone?: string;
localTimeZone: string;
locales: string[];
}) {
return (
<div className="flex flex-col gap-1">
<div className="flex flex-col gap-2.5 pb-1">
{timeZone && timeZone !== "UTC" && (
<DateTimeTooltipContent
title={timeZone}
dateTime={formatDateTime(realDate, timeZone, locales, true, true, true)}
isoDateTime={formatDateTimeISO(realDate, timeZone)}
icon={<GlobeAmericasIcon className="size-4 text-purple-500" />}
/>
)}
<DateTimeTooltipContent
title="UTC"
dateTime={formatDateTime(realDate, "UTC", locales, true, true, true)}
isoDateTime={formatDateTimeISO(realDate, "UTC")}
icon={<GlobeAltIcon className="size-4 text-blue-500" />}
/>
<DateTimeTooltipContent
title="Local"
dateTime={formatDateTime(realDate, localTimeZone, locales, true, true, true)}
isoDateTime={formatDateTimeISO(realDate, localTimeZone)}
icon={<Laptop className="size-4 text-green-500" />}
offset={formatUtcOffset(realDate, localTimeZone)}
/>
</div>
</div>
);
}
@@ -0,0 +1,145 @@
"use client";
import * as React from "react";
import { ChevronUpDownIcon } from "@heroicons/react/20/solid";
import { format } from "date-fns";
import { Calendar } from "./Calendar";
import { Popover, PopoverContent, PopoverTrigger } from "./Popover";
import { Button } from "./Buttons";
import { cn } from "~/utils/cn";
import { SimpleTooltip } from "./Tooltip";
import { XIcon } from "lucide-react";
type DateTimePickerProps = {
label: string;
value?: Date;
onChange?: (date: Date | undefined) => void;
showSeconds?: boolean;
showNowButton?: boolean;
showClearButton?: boolean;
showInlineLabel?: boolean;
className?: string;
};
export function DateTimePicker({
label,
value,
onChange,
showSeconds = true,
showNowButton = false,
showClearButton = false,
showInlineLabel = false,
className,
}: DateTimePickerProps) {
const [open, setOpen] = React.useState(false);
// Extract time parts from value
const hours = value ? value.getHours().toString().padStart(2, "0") : "";
const minutes = value ? value.getMinutes().toString().padStart(2, "0") : "";
const seconds = value ? value.getSeconds().toString().padStart(2, "0") : "";
const timeValue = showSeconds ? `${hours}:${minutes}:${seconds}` : `${hours}:${minutes}`;
const handleDateSelect = (date: Date | undefined) => {
if (date) {
// Preserve the time from the current value if it exists
if (value) {
date.setHours(value.getHours());
date.setMinutes(value.getMinutes());
date.setSeconds(value.getSeconds());
}
onChange?.(date);
} else {
onChange?.(undefined);
}
setOpen(false);
};
const handleTimeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const timeString = e.target.value;
if (!timeString) return;
const [h, m, s] = timeString.split(":").map(Number);
const newDate = value ? new Date(value) : new Date();
newDate.setHours(h || 0);
newDate.setMinutes(m || 0);
newDate.setSeconds(s || 0);
onChange?.(newDate);
};
const handleNowClick = () => {
onChange?.(new Date());
};
const handleClearClick = () => {
onChange?.(undefined);
};
return (
<div className={cn("flex items-center gap-2", className)}>
{showInlineLabel && (
<span className="w-6 shrink-0 text-right text-xxs text-text-faint">{label}</span>
)}
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
className={cn(
"flex h-[1.8rem] w-full items-center justify-between gap-2 whitespace-nowrap rounded border border-border-bright bg-background-hover px-2 text-xs tabular-nums transition hover:border-border-bright",
value ? "text-text-bright" : "text-text-dimmed"
)}
>
{value ? format(value, "yyyy/MM/dd") : "Select date"}
<ChevronUpDownIcon className="size-3.5 text-text-dimmed" />
</button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={value}
onSelect={handleDateSelect}
captionLayout="dropdown"
/>
</PopoverContent>
</Popover>
<input
type="time"
step={showSeconds ? "1" : "60"}
value={value ? timeValue : ""}
onChange={handleTimeChange}
className={cn(
"h-[1.8rem] rounded border border-border-bright bg-background-hover px-2 text-xs tabular-nums transition hover:border-border-bright",
value ? "text-text-bright" : "text-text-dimmed",
"focus:border-border-brightest focus:outline-hidden",
"[&::-webkit-calendar-picker-indicator]:hidden [&::-webkit-calendar-picker-indicator]:appearance-none"
)}
aria-label={`${label} time`}
/>
{showNowButton && (
<Button
type="button"
variant="secondary/small"
className="h-[1.8rem]"
onClick={handleNowClick}
>
Now
</Button>
)}
{showClearButton && (
<SimpleTooltip
button={
<button
type="button"
className="flex h-[1.8rem] items-center justify-center px-1 text-text-dimmed transition hover:text-text-bright"
onClick={handleClearClick}
>
<XIcon className="size-3.5" />
</button>
}
content="Clear"
disableHoverableContent
asChild
/>
)}
</div>
);
}
@@ -0,0 +1,88 @@
import { cn } from "~/utils/cn";
import type { RenderIcon } from "./Icon";
import { Icon, IconInBox } from "./Icon";
import { Paragraph } from "./Paragraph";
const variations = {
small: {
label: {
variant: "small" as const,
className: "m-0 leading-[1.1rem]",
},
description: {
variant: "extra-small" as const,
className: "m-0",
},
},
base: {
label: {
variant: "base" as const,
className: "m-0 leading-[1.1rem] ",
},
description: {
variant: "small" as const,
className: "m-0",
},
},
};
type DetailCellProps = {
leadingIcon?: RenderIcon;
leadingIconClassName?: string;
trailingIcon?: RenderIcon;
trailingIconClassName?: string;
label: string | React.ReactNode;
description?: string | React.ReactNode;
className?: string;
variant?: keyof typeof variations;
boxClassName?: string;
};
export function DetailCell({
leadingIcon,
leadingIconClassName,
trailingIcon,
trailingIconClassName,
label,
description,
className,
variant = "small",
boxClassName,
}: DetailCellProps) {
const variation = variations[variant];
return (
<div className={cn("group flex h-11 w-full items-center gap-3 rounded-md p-1 pr-3", className)}>
<IconInBox
icon={leadingIcon}
className={cn("flex-none transition group-hover:border-grid-dimmed", leadingIconClassName)}
boxClassName={boxClassName}
/>
<div className="flex flex-1 flex-col">
<Paragraph
variant={variation.label.variant}
className={cn("flex-1 text-left", variation.label.className)}
>
{label}
</Paragraph>
{description && (
<Paragraph
variant={variation.description.variant}
className={cn("flex-1 text-left text-text-dimmed", variation.description.className)}
>
{description}
</Paragraph>
)}
</div>
<div className="flex flex-none items-center gap-1">
<Icon
icon={trailingIcon}
className={cn(
"h-6 w-6 flex-none transition group-hover:border-grid-dimmed",
trailingIconClassName
)}
/>
</div>
</div>
);
}
@@ -0,0 +1,126 @@
"use client";
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { cn } from "~/utils/cn";
import { XMarkIcon } from "@heroicons/react/24/solid";
import { ShortcutKey } from "./ShortcutKey";
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = ({ children, ...props }: DialogPrimitive.DialogPortalProps) => (
<DialogPrimitive.Portal {...props}>
<div className="fixed inset-0 z-50 flex items-start justify-center sm:items-center">
{children}
</div>
</DialogPrimitive.Portal>
);
DialogPortal.displayName = DialogPrimitive.Portal.displayName;
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-background-dimmed/90 backdrop-blur-xs transition-all duration-100 data-[state=closed]:animate-out data-[state=closed]:fade-out data-[state=open]:fade-in",
className
)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean;
fullscreen?: boolean;
}
>(({ className, children, showCloseButton = true, fullscreen = false, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed z-50 grid gap-4 border bg-background-dimmed shadow-lg animate-in data-[state=open]:fade-in-90",
fullscreen
? "inset-6 rounded-lg pt-2.5 px-4 pb-4"
: "w-full rounded-b-lg px-4 pb-4 pt-2.5 data-[state=open]:slide-in-from-bottom-10 sm:max-w-lg sm:rounded-lg sm:zoom-in-90 sm:data-[state=open]:slide-in-from-bottom-0",
className
)}
{...props}
>
<hr className="absolute left-0 top-11 w-full" />
{children}
{showCloseButton && (
<DialogPrimitive.Close className="data-[state=open]:bg-accent data-[state=open]:text-muted-foreground group absolute right-2 top-2.25 flex items-center gap-1 rounded-sm p-1 py-1 pl-0 pr-1 opacity-70 transition focus-custom hover:bg-background-hover hover:opacity-100 focus-visible:focus-custom disabled:pointer-events-none">
<ShortcutKey
shortcut={{
key: "esc",
}}
variant="medium"
/>
<XMarkIcon className="size-4 text-text-dimmed transition group-hover:text-text-bright" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("flex flex-col text-left font-medium text-text-bright", className)}
{...props}
/>
);
DialogHeader.displayName = "DialogHeader";
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("flex justify-between border-t border-grid-bright pt-4", className)}
{...props}
/>
);
DialogFooter.displayName = "DialogFooter";
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn("text-base font-normal text-text-bright", className)}
{...props}
/>
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("pt-2 text-base text-text-dimmed", className)}
{...props}
/>
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
DialogPortal,
DialogOverlay,
};
@@ -0,0 +1,201 @@
import { Input } from "~/components/primitives/Input";
import { cn } from "~/utils/cn";
import React, { useRef, useState, useEffect } from "react";
import { Button } from "./Buttons";
export interface DurationPickerProps {
id?: string; // used for the hidden input for form submission
name?: string; // used for the hidden input for form submission
defaultValueSeconds?: number;
value?: number;
onChange?: (totalSeconds: number) => void;
variant?: "small" | "medium";
showClearButton?: boolean;
}
export function DurationPicker({
name,
defaultValueSeconds: defaultValue = 0,
value: controlledValue,
onChange,
variant = "small",
showClearButton = true,
}: DurationPickerProps) {
// Use controlled value if provided, otherwise use default
const initialValue = controlledValue ?? defaultValue;
const defaultHours = Math.floor(initialValue / 3600);
const defaultMinutes = Math.floor((initialValue % 3600) / 60);
const defaultSeconds = initialValue % 60;
const [hours, setHours] = useState<number>(defaultHours);
const [minutes, setMinutes] = useState<number>(defaultMinutes);
const [seconds, setSeconds] = useState<number>(defaultSeconds);
const minuteRef = useRef<HTMLInputElement>(null);
const hourRef = useRef<HTMLInputElement>(null);
const secondRef = useRef<HTMLInputElement>(null);
const totalSeconds = hours * 3600 + minutes * 60 + seconds;
const isEmpty = hours === 0 && minutes === 0 && seconds === 0;
// Sync internal state with external value changes
useEffect(() => {
if (controlledValue !== undefined && controlledValue !== totalSeconds) {
const newHours = Math.floor(controlledValue / 3600);
const newMinutes = Math.floor((controlledValue % 3600) / 60);
const newSeconds = controlledValue % 60;
setHours(newHours);
setMinutes(newMinutes);
setSeconds(newSeconds);
}
}, [controlledValue]);
useEffect(() => {
onChange?.(totalSeconds);
}, [totalSeconds, onChange]);
const handleHoursChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = parseInt(e.target.value) || 0;
setHours(Math.max(0, value));
};
const handleMinutesChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = parseInt(e.target.value) || 0;
if (value >= 60) {
setHours((prev) => prev + Math.floor(value / 60));
setMinutes(value % 60);
return;
}
setMinutes(Math.max(0, Math.min(59, value)));
};
const handleSecondsChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = parseInt(e.target.value) || 0;
if (value >= 60) {
setMinutes((prev) => {
const newMinutes = prev + Math.floor(value / 60);
if (newMinutes >= 60) {
setHours((prevHours) => prevHours + Math.floor(newMinutes / 60));
return newMinutes % 60;
}
return newMinutes;
});
setSeconds(value % 60);
return;
}
setSeconds(Math.max(0, Math.min(59, value)));
};
const handleKeyDown = (
e: React.KeyboardEvent<HTMLInputElement>,
nextRef?: React.RefObject<HTMLInputElement>,
prevRef?: React.RefObject<HTMLInputElement>
) => {
if (e.key === "Tab") {
return;
}
if (e.key === "ArrowRight" && nextRef) {
e.preventDefault();
nextRef.current?.focus();
nextRef.current?.select();
return;
}
if (e.key === "ArrowLeft" && prevRef) {
e.preventDefault();
prevRef.current?.focus();
prevRef.current?.select();
return;
}
};
const clearDuration = () => {
setHours(0);
setMinutes(0);
setSeconds(0);
hourRef.current?.focus();
};
return (
<div className="flex items-center gap-3">
<input type="hidden" name={name} value={totalSeconds} />
<div className="flex items-center gap-1">
<div className="group flex items-center gap-1">
<Input
variant={variant}
ref={hourRef}
className={cn(
"w-10 text-center font-mono tabular-nums caret-transparent [&::-webkit-inner-spin-button]:appearance-none",
isEmpty && "text-text-dimmed"
)}
value={hours.toString()}
onChange={handleHoursChange}
onKeyDown={(e) => handleKeyDown(e, minuteRef)}
onFocus={(e) => e.target.select()}
type="number"
min={0}
inputMode="numeric"
/>
<span className="text-sm text-text-dimmed transition-colors duration-200 group-focus-within:text-text-bright/80">
h
</span>
</div>
<div className="group flex items-center gap-1">
<Input
variant={variant}
ref={minuteRef}
className={cn(
"w-10 text-center font-mono tabular-nums caret-transparent [&::-webkit-inner-spin-button]:appearance-none",
isEmpty && "text-text-dimmed"
)}
value={minutes.toString()}
onChange={handleMinutesChange}
onKeyDown={(e) => handleKeyDown(e, secondRef, hourRef)}
onFocus={(e) => e.target.select()}
type="number"
min={0}
max={59}
inputMode="numeric"
/>
<span className="text-sm text-text-dimmed transition-colors duration-200 group-focus-within:text-text-bright/80">
m
</span>
</div>
<div className="group flex items-center gap-1">
<Input
variant={variant}
ref={secondRef}
className={cn(
"w-10 text-center font-mono tabular-nums caret-transparent [&::-webkit-inner-spin-button]:appearance-none",
isEmpty && "text-text-dimmed"
)}
value={seconds.toString()}
onChange={handleSecondsChange}
onKeyDown={(e) => handleKeyDown(e, undefined, minuteRef)}
onFocus={(e) => e.target.select()}
type="number"
min={0}
max={59}
inputMode="numeric"
/>
<span className="text-sm text-text-dimmed transition-colors duration-200 group-focus-within:text-text-bright/80">
s
</span>
</div>
</div>
{showClearButton && (
<Button type="button" variant={`tertiary/${variant}`} onClick={clearDuration}>
Clear
</Button>
)}
</div>
);
}
@@ -0,0 +1,11 @@
import { cn } from "~/utils/cn";
export function Fieldset({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
return <div className={cn("flex flex-col gap-y-5", className)}>{children}</div>;
}
@@ -0,0 +1,35 @@
import { cn } from "~/utils/cn";
export function FormButtons({
cancelButton,
confirmButton,
defaultAction,
className,
}: {
cancelButton?: React.ReactNode;
confirmButton?: React.ReactNode;
defaultAction?: { name: string; value: string; disabled?: boolean };
className?: string;
}) {
return (
<div
className={cn(
"flex w-full items-center justify-between border-t border-grid-bright pt-4",
className
)}
>
{defaultAction && (
<button
type="submit"
name={defaultAction.name}
value={defaultAction.value}
disabled={defaultAction.disabled}
className="hidden"
tabIndex={-1}
aria-hidden="true"
/>
)}
{cancelButton ? cancelButton : <div />} {confirmButton ?? null}
</div>
);
}
@@ -0,0 +1,55 @@
import type { z } from "zod";
import { Paragraph } from "./Paragraph";
import { motion } from "framer-motion";
import { cn } from "~/utils/cn";
import { ErrorIcon } from "~/assets/icons/ErrorIcon";
export function FormError({
children,
id,
className,
}: {
children: React.ReactNode;
id?: string;
className?: string;
}) {
return (
<>
{children && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3 }}
className={cn("flex items-start gap-0.5", className)}
>
<ErrorIcon className="h-4 w-4 shrink-0 justify-start text-rose-500" />
<Paragraph id={id} variant="extra-small" className="text-rose-500">
{children}
</Paragraph>
</motion.div>
)}
</>
);
}
export function ZodFormErrors({ errors, path }: { errors: z.ZodIssue[]; path: string[] }) {
if (errors.length === 0) {
return null;
}
const relevantErrors = errors.filter((error) => {
return error.path.join(".") === path.join(".");
});
if (relevantErrors.length === 0) {
return null;
}
return (
<div className="col-span-full mt-1 text-sm text-rose-600">
{relevantErrors.map((error, index) => (
<FormError key={index}>{error.message}</FormError>
))}
</div>
);
}
@@ -0,0 +1,33 @@
import { cn } from "~/utils/cn";
import { Header1 } from "./Headers";
import { Paragraph } from "./Paragraph";
export function FormTitle({
title,
description,
LeadingIcon,
divide = true,
className,
}: {
title: React.ReactNode;
description?: React.ReactNode;
LeadingIcon?: React.ReactNode;
divide?: boolean;
className?: string;
}) {
return (
<div
className={cn(
"mb-4 flex flex-col gap-3 pb-4",
divide ? "border-b border-grid-bright" : "",
className
)}
>
<div className="flex items-center gap-2">
{LeadingIcon && <div className="shrink-0 justify-start">{LeadingIcon}</div>}
<Header1>{title}</Header1>
</div>
{description && <Paragraph variant="small">{description}</Paragraph>}
</div>
);
}
@@ -0,0 +1,90 @@
import { cn } from "~/utils/cn";
const headerVariants = {
header1: {
text: "font-sans text-2xl leading-5 md:leading-6 lg:leading-7 font-semibold tracking-tight",
spacing: "mb-2",
},
header2: {
text: "font-sans text-base leading-6 font-semibold tracking-tight",
spacing: "mb-2",
},
header3: {
text: "font-sans text-sm leading-5 font-semibold",
spacing: "mb-2",
},
};
const textColorVariants = {
bright: "text-text-bright",
dimmed: "text-text-dimmed",
};
export type HeaderVariant = keyof typeof headerVariants;
type HeaderProps = {
className?: string;
children: React.ReactNode;
spacing?: boolean;
textColor?: "bright" | "dimmed";
} & React.HTMLAttributes<HTMLHeadingElement>;
export function Header1({
className,
children,
spacing = false,
textColor = "bright",
}: HeaderProps) {
return (
<h1
className={cn(
headerVariants.header1.text,
spacing === true && headerVariants.header1.spacing,
textColor === "bright" ? textColorVariants.bright : textColorVariants.dimmed,
className
)}
>
{children}
</h1>
);
}
export function Header2({
className,
children,
spacing = false,
textColor = "bright",
}: HeaderProps) {
return (
<h2
className={cn(
headerVariants.header2.text,
spacing === true && headerVariants.header2.spacing,
textColor === "bright" ? textColorVariants.bright : textColorVariants.dimmed,
className
)}
>
{children}
</h2>
);
}
export function Header3({
className,
children,
spacing = false,
textColor = "bright",
}: HeaderProps) {
return (
<h3
className={cn(
headerVariants.header3.text,
spacing === true && headerVariants.header3.spacing,
textColor === "bright" ? textColorVariants.bright : textColorVariants.dimmed,
className
)}
>
{children}
</h3>
);
}
@@ -0,0 +1,9 @@
import { Paragraph } from "./Paragraph";
export function Hint({ children, className }: { children: React.ReactNode; className?: string }) {
return (
<Paragraph variant="extra-small" className={className}>
{children}
</Paragraph>
);
}
@@ -0,0 +1,50 @@
import React, { type FunctionComponent, createElement } from "react";
import { cn } from "~/utils/cn";
export type RenderIcon = FunctionComponent<{ className?: string }> | React.ReactNode;
export type IconProps = {
icon?: RenderIcon;
className?: string;
};
/** Use this icon to either render a passed in React component, or a NamedIcon/CompanyIcon */
export function Icon(props: IconProps) {
if (!props.icon) return null;
if (typeof props.icon === "function") {
const Icon = props.icon;
return <Icon className={props.className} />;
}
if (React.isValidElement(props.icon)) {
return <>{props.icon}</>;
}
if (
props.icon &&
typeof props.icon === "object" &&
("type" in props.icon || "$$typeof" in props.icon)
) {
return createElement<FunctionComponent<any>>(
props.icon as any,
{ className: props.className } as any
);
}
console.error("Invalid icon", props);
return null;
}
export function IconInBox({ boxClassName, ...props }: IconProps & { boxClassName?: string }) {
return (
<div
className={cn(
"grid h-9 w-9 place-content-center rounded-sm border border-grid-dimmed bg-background-dimmed",
boxClassName
)}
>
<Icon icon={props.icon} className={cn("h-6 w-6", props.className)} />
</div>
);
}
@@ -0,0 +1,68 @@
import { cn } from "~/utils/cn";
import { Header2 } from "./Headers";
import { Paragraph } from "./Paragraph";
import { type ReactNode } from "react";
const variants = {
info: {
panelStyle: "border-grid-bright bg-background-bright rounded-md border p-4 gap-3",
},
upgrade: {
panelStyle: "border-indigo-400/20 bg-indigo-800/10 rounded-md border p-4 gap-3",
},
minimal: {
panelStyle: "max-w-full w-full py-3 px-3 gap-2",
},
};
type InfoPanelVariant = keyof typeof variants;
type Props = {
title?: string;
children: React.ReactNode;
accessory?: ReactNode;
icon: React.ComponentType<any>;
iconClassName?: string;
variant?: InfoPanelVariant;
panelClassName?: string;
};
export function InfoPanel({
title,
children,
accessory,
icon,
iconClassName,
variant = "info",
panelClassName = "max-w-sm",
}: Props) {
const Icon = icon;
const variantStyle = variants[variant];
return (
<div
className={cn(
variantStyle.panelStyle,
title ? "flex-col" : "",
"flex h-fit items-start",
panelClassName
)}
>
<div className={cn("flex items-center gap-2", accessory ? "w-full justify-between" : "")}>
<Icon className={cn("size-5", iconClassName)} />
{accessory}
</div>
<div className="flex flex-col gap-1">
{title && <Header2 className="text-text-bright">{title}</Header2>}
{typeof children === "string" ? (
<Paragraph variant={"small"} className="text-text-dimmed">
{children}
</Paragraph>
) : (
children
)}
</div>
</div>
);
}
@@ -0,0 +1,130 @@
import * as React from "react";
import { useImperativeHandle, useRef } from "react";
import { cn } from "~/utils/cn";
import { Icon, type RenderIcon } from "./Icon";
const containerBase =
"has-focus-visible:outline-hidden has-focus-visible:ring-1 has-focus-visible:ring-border-bright has-focus-visible:ring-offset-0 has-[:focus]:border-ring has-focus:outline-hidden has-focus:ring-1 has-[:focus]:ring-ring has-disabled:cursor-not-allowed has-disabled:opacity-50 ring-offset-background transition cursor-text";
const inputBase =
"h-full w-full text-text-bright bg-transparent file:border-0 file:bg-transparent file:text-base file:font-medium placeholder:text-muted-foreground focus-visible:outline-hidden focus-visible:ring-0 disabled:cursor-not-allowed outline-hidden ring-0 border-none [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:m-0 [&::-webkit-inner-spin-button]:m-0 [&]:[-moz-appearance:textfield]";
const variants = {
large: {
container:
"px-1 w-full h-10 rounded-[3px] border border-background-bright bg-background-hover hover:border-border-bright hover:bg-secondary",
input: "px-2 text-sm",
iconSize: "size-4 ml-1",
accessory: "pr-1",
},
medium: {
container:
"px-1 h-8 w-full rounded border border-background-bright bg-background-hover hover:border-border-bright hover:bg-secondary",
input: "px-1.5 rounded text-sm",
iconSize: "size-4 ml-0.5",
accessory: "pr-1",
},
small: {
container:
"px-1 h-6 w-full rounded border border-background-bright bg-background-hover hover:border-border-bright hover:bg-secondary",
input: "px-1 rounded text-xs",
iconSize: "size-3 ml-0.5",
accessory: "pr-0.5",
},
tertiary: {
container: "px-1 h-6 w-full rounded hover:bg-background-hover",
input: "px-1 rounded text-xs",
iconSize: "size-3 ml-0.5",
accessory: "pr-0.5",
},
"secondary-small": {
container:
"px-1 h-6 w-full rounded border border-border-bright hover:border-border-brighter bg-grid-dimmed hover:bg-secondary",
input: "px-1 rounded text-xs",
iconSize: "size-3 ml-0.5",
accessory: "pr-0.5",
},
"outline/large": {
container: "px-1 h-10 w-full rounded border border-grid-bright hover:border-border-brighter",
input: "px-2 rounded text-sm",
iconSize: "size-4 ml-1",
accessory: "pr-1",
},
"outline/medium": {
container: "px-1 h-8 w-full rounded border border-grid-bright hover:border-border-brighter",
input: "px-1 rounded text-sm",
iconSize: "size-4 ml-0.5",
accessory: "pr-1",
},
"outline/small": {
container: "px-1 h-6 w-full rounded border border-grid-bright hover:border-border-brighter",
input: "px-1 rounded text-xs",
iconSize: "size-3 ml-0.5",
accessory: "pr-0.5",
},
};
export type InputProps = React.InputHTMLAttributes<HTMLInputElement> & {
variant?: keyof typeof variants;
icon?: RenderIcon;
iconClassName?: string;
accessory?: React.ReactNode;
fullWidth?: boolean;
containerClassName?: string;
};
const Input = React.forwardRef<HTMLInputElement, InputProps>(
(
{
className,
type,
accessory,
fullWidth = true,
variant = "medium",
icon,
iconClassName,
containerClassName,
...props
},
ref
) => {
const innerRef = useRef<HTMLInputElement>(null);
useImperativeHandle(ref, () => innerRef.current as HTMLInputElement);
const variantContainerClassName = variants[variant].container;
const inputClassName = variants[variant].input;
const variantIconClassName = variants[variant].iconSize;
return (
<div
className={cn(
"flex items-center",
containerBase,
variantContainerClassName,
containerClassName,
fullWidth ? "w-full" : "max-w-max"
)}
onClick={() => innerRef.current && innerRef.current.focus()}
>
{icon && (
<div className="pointer-events-none flex items-center">
<Icon
icon={icon}
className={cn(variantIconClassName, "text-text-dimmed", iconClassName)}
/>
</div>
)}
<input
type={type}
className={cn("grow", inputBase, inputClassName, className)}
ref={innerRef}
{...props}
/>
{accessory && <div className={cn(variants[variant].accessory)}>{accessory}</div>}
</div>
);
}
);
Input.displayName = "Input";
export { Input };
@@ -0,0 +1,21 @@
import { cn } from "~/utils/cn";
type InputGroupProps = {
children: React.ReactNode;
className?: string;
fullWidth?: boolean;
};
export function InputGroup({ children, className, fullWidth }: InputGroupProps) {
return (
<div
className={cn(
"grid w-full items-center gap-1.5",
fullWidth ? "w-full" : "max-w-md",
className
)}
>
{children}
</div>
);
}
@@ -0,0 +1,220 @@
import { MinusIcon, PlusIcon } from "@heroicons/react/20/solid";
import { type ChangeEvent, useRef } from "react";
import { cn } from "~/utils/cn";
type InputNumberStepperProps = Omit<JSX.IntrinsicElements["input"], "min" | "max" | "step"> & {
step?: number;
min?: number;
max?: number;
round?: boolean;
controlSize?: "base" | "large";
};
export function InputNumberStepper({
value,
onChange,
step = 50,
min,
max,
round = true,
controlSize = "base",
name,
id,
disabled = false,
readOnly = false,
className,
placeholder = "Type a number",
...props
}: InputNumberStepperProps) {
const inputRef = useRef<HTMLInputElement>(null);
const handleStepUp = () => {
if (!inputRef.current || disabled) return;
// If rounding is enabled, ensure we start from a rounded base before stepping
if (round) {
// If field is empty, treat as 0 (or min if provided) before stepping up
if (inputRef.current.value === "") {
inputRef.current.value = String(min ?? 0);
} else {
commitRoundedFromInput();
}
}
inputRef.current.stepUp();
const event = new Event("change", { bubbles: true });
inputRef.current.dispatchEvent(event);
};
const handleStepDown = () => {
if (!inputRef.current || disabled) return;
// If rounding is enabled, ensure we start from a rounded base before stepping
if (round) {
// If field is empty, treat as 0 (or min if provided) before stepping down
if (inputRef.current.value === "") {
inputRef.current.value = String(min ?? 0);
} else {
commitRoundedFromInput();
}
}
inputRef.current.stepDown();
const event = new Event("change", { bubbles: true });
inputRef.current.dispatchEvent(event);
};
const numericValue = value === "" ? NaN : (value as number);
const isMinDisabled = min !== undefined && !Number.isNaN(numericValue) && numericValue <= min;
const isMaxDisabled = max !== undefined && !Number.isNaN(numericValue) && numericValue >= max;
function clamp(val: number): number {
if (Number.isNaN(val)) return typeof value === "number" ? value : (min ?? 0);
let next = val;
if (min !== undefined) next = Math.max(min, next);
if (max !== undefined) next = Math.min(max, next);
return next;
}
function roundToStep(val: number): number {
if (step <= 0) return val;
const base = min ?? 0;
const shifted = val - base;
const quotient = shifted / step;
const floored = Math.floor(quotient);
const ceiled = Math.ceil(quotient);
const down = base + floored * step;
const up = base + ceiled * step;
const distDown = Math.abs(val - down);
const distUp = Math.abs(up - val);
return distUp < distDown ? up : down;
}
function commitRoundedFromInput() {
if (!inputRef.current || disabled || readOnly) return;
const el = inputRef.current;
const raw = el.value;
if (raw === "") return; // do not coerce empty to 0; keep placeholder visible
const numeric = Number(raw);
if (Number.isNaN(numeric)) return; // ignore non-numeric
const rounded = clamp(roundToStep(numeric));
if (String(rounded) === String(value)) return;
// Update the real input's value for immediate UI feedback
el.value = String(rounded);
// Invoke consumer onChange with the real element as target/currentTarget
onChange?.({
target: el,
currentTarget: el,
} as unknown as ChangeEvent<HTMLInputElement>);
}
const sizeStyles = {
base: {
container: "h-9",
input: "text-sm px-3",
button: "size-6",
icon: "size-3.5",
gap: "gap-1 pr-1.5",
},
large: {
container: "h-11 rounded-md",
input: "text-base px-3.5",
button: "size-8",
icon: "size-5",
gap: "gap-1.25 pr-1.25",
},
} as const;
const size = sizeStyles[controlSize];
return (
<div
className={cn(
"flex items-center rounded border border-border-bright bg-tertiary transition hover:border-border-brighter/80 hover:bg-surface-control/80",
size.container,
"has-focus-visible:outline-solid has-focus-visible:outline-1 has-focus-visible:outline-offset-0 has-focus-visible:outline-text-link",
disabled && "cursor-not-allowed opacity-50",
className
)}
>
<input
ref={inputRef}
type="number"
id={id}
name={name}
value={value}
placeholder={placeholder}
onChange={(e) => {
// Allow empty string to pass through so user can clear the field
if (e.currentTarget.value === "") {
// reflect emptiness in the input and notify consumer as empty
if (inputRef.current) inputRef.current.value = "";
onChange?.({
target: e.currentTarget,
currentTarget: e.currentTarget,
} as ChangeEvent<HTMLInputElement>);
return;
}
onChange?.(e);
}}
onBlur={(e) => {
// If blur is caused by clicking our step buttons, we prevent pointerdown
// so blur shouldn't fire. This is for safety in case of keyboard focus move.
if (round) commitRoundedFromInput();
}}
onKeyDown={(e) => {
if (e.key === "Enter" && round) {
e.preventDefault();
commitRoundedFromInput();
}
}}
step={step}
min={min}
max={max}
disabled={disabled}
readOnly={readOnly}
className={cn(
"placeholder:text-muted-foreground h-full grow border-0 bg-transparent text-left text-text-bright outline-hidden ring-0 focus:border-0 focus:outline-hidden focus:ring-0 disabled:cursor-not-allowed",
size.input,
// Hide number input arrows
"[&[type=number]]:border-0 [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
)}
{...props}
/>
<div className={cn("flex items-center", size.gap)}>
<button
type="button"
onClick={handleStepDown}
onPointerDown={(e) => e.preventDefault()}
disabled={disabled || isMinDisabled}
aria-label={`Decrease by ${step}`}
className={cn(
"flex items-center justify-center rounded border border-error/30 bg-error/20 transition",
size.button,
"hover:border-error/50 hover:bg-error/30",
"disabled:cursor-not-allowed disabled:opacity-40",
"focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-text-link"
)}
>
<MinusIcon className={cn("text-error", size.icon)} />
</button>
<button
type="button"
onClick={handleStepUp}
onPointerDown={(e) => e.preventDefault()}
disabled={disabled || isMaxDisabled}
aria-label={`Increase by ${step}`}
className={cn(
"flex items-center justify-center rounded border border-success/30 bg-success/10 transition",
size.button,
"hover:border-success/40 hover:bg-success/20",
"disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent",
"focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-text-link"
)}
>
<PlusIcon className={cn("text-success", size.icon)} />
</button>
</div>
</div>
);
}
@@ -0,0 +1,110 @@
"use client";
import * as React from "react";
import { OTPInput, OTPInputContext } from "input-otp";
import { MinusIcon } from "lucide-react";
import { cn } from "~/utils/cn";
const variants = {
default: {
container: "flex items-center gap-2 has-disabled:opacity-50",
group: "flex items-center",
slot: "data-[active=true]:border-ring data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:ring-destructive/20 dark:data-[active=true]:aria-invalid:ring-destructive/40 aria-invalid:border-destructive data-[active=true]:aria-invalid:border-destructive dark:bg-input/30 border-input relative flex size-9 items-center justify-center border-y border-r text-sm outline-hidden transition-all first:rounded-l-md first:border-l last:rounded-r-md data-[active=true]:z-10 data-[active=true]:ring-[3px]",
},
large: {
container: "flex items-center gap-3 has-disabled:opacity-50",
group: "flex items-center gap-1",
slot: "data-[active=true]:border-ring data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:ring-destructive/20 dark:data-[active=true]:aria-invalid:ring-destructive/40 aria-invalid:border-destructive data-[active=true]:aria-invalid:border-destructive bg-background-hover border-grid-bright hover:border-border-bright hover:bg-secondary relative flex h-12 w-12 items-center justify-center border text-base outline-hidden transition-all rounded-md data-[active=true]:z-10 data-[active=true]:ring-[3px] data-[active=true]:border-indigo-500",
},
minimal: {
container: "flex items-center gap-2 has-disabled:opacity-50",
group: "flex items-center",
slot: "data-[active=true]:border-ring data-[active=true]:ring-ring/50 border-transparent bg-transparent relative flex h-9 w-9 items-center justify-center border-b-2 border-b-border-bright text-sm outline-hidden transition-all data-[active=true]:border-b-indigo-500 data-[active=true]:z-10",
},
};
function InputOTP({
className,
containerClassName,
variant = "default",
fullWidth = false,
...props
}: React.ComponentProps<typeof OTPInput> & {
containerClassName?: string;
variant?: keyof typeof variants;
fullWidth?: boolean;
}) {
const variantStyles = variants[variant];
return (
<OTPInput
data-slot="input-otp"
containerClassName={cn(variantStyles.container, fullWidth && "w-full", containerClassName)}
className={cn("disabled:cursor-not-allowed", className)}
{...props}
/>
);
}
function InputOTPGroup({
className,
variant = "default",
fullWidth = false,
...props
}: React.ComponentProps<"div"> & {
variant?: keyof typeof variants;
fullWidth?: boolean;
}) {
const variantStyles = variants[variant];
return (
<div
data-slot="input-otp-group"
className={cn(variantStyles.group, fullWidth && "flex-1 gap-1", className)}
{...props}
/>
);
}
function InputOTPSlot({
index,
className,
variant = "default",
fullWidth = false,
...props
}: React.ComponentProps<"div"> & {
index: number;
variant?: keyof typeof variants;
fullWidth?: boolean;
}) {
const inputOTPContext = React.useContext(OTPInputContext);
const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {};
const variantStyles = variants[variant];
return (
<div
data-slot="input-otp-slot"
data-active={isActive}
className={cn(variantStyles.slot, fullWidth && "flex-1", className)}
{...props}
>
{char}
{hasFakeCaret && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="animate-caret-blink h-4 w-px bg-text-bright duration-1000" />
</div>
)}
</div>
);
}
function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) {
return (
<div data-slot="input-otp-separator" role="separator" {...props}>
<MinusIcon />
</div>
);
}
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator };
@@ -0,0 +1,41 @@
import * as React from "react";
import { cn } from "~/utils/cn";
import { InfoIconTooltip } from "./Tooltip";
const variants = {
small: {
text: "font-sans text-[0.8125rem] font-normal text-text-bright leading-tight flex items-center gap-1",
},
medium: {
text: "font-sans text-sm text-text-bright leading-tight flex items-center gap-1",
},
large: {
text: "font-sans text-base font-medium text-text-bright leading-tight flex items-center gap-1",
},
};
type LabelProps = React.AllHTMLAttributes<HTMLLabelElement> & {
className?: string;
children: React.ReactNode;
variant?: keyof typeof variants;
required?: boolean;
tooltip?: React.ReactNode;
};
export function Label({
className,
children,
variant = "medium",
required = true,
tooltip,
...props
}: LabelProps) {
const variation = variants[variant];
return (
<label className={cn(variation.text, className)} {...props}>
{children}
{tooltip ? <InfoIconTooltip content={tooltip} /> : null}
{!required && <span className="text-text-dimmed"> (optional)</span>}
</label>
);
}
@@ -0,0 +1,93 @@
import { cn } from "~/utils/cn";
import { Paragraph } from "./Paragraph";
import { ArrowTopRightOnSquareIcon } from "@heroicons/react/20/solid";
import { SimpleTooltip } from "./Tooltip";
import { Link } from "@remix-run/react";
const variations = {
primary: {
label: "extra-small/bright",
value: "extra-small",
},
secondary: {
label: "extra-extra-small/caps",
value: "extra-small/bright",
},
} as const;
type LabelValueStackProps = {
label: React.ReactNode;
value: React.ReactNode;
href?: string;
layout?: "horizontal" | "vertical";
variant?: keyof typeof variations;
className?: string;
};
export function LabelValueStack({
label,
value,
href,
layout = "vertical",
variant = "secondary",
className,
}: LabelValueStackProps) {
const variation = variations[variant];
return (
<div
className={cn(
"flex items-baseline",
layout === "vertical" && "flex-col",
variant === "primary" ? "gap-x-1 gap-y-0" : "gap-x-1 gap-y-0.5",
className
)}
>
<Paragraph variant={variation.label}>{label}</Paragraph>
<>
{href ? (
<ValueButton value={value} href={href} variant={variant} />
) : (
<Paragraph variant={variation.value}>{value}</Paragraph>
)}
</>
</div>
);
}
type ValueButtonStackProps = {
value: React.ReactNode;
href: string;
variant?: keyof typeof variations;
};
function ValueButton({ value, href, variant = "secondary" }: ValueButtonStackProps) {
const variation = variations[variant];
const isExternalUrl = href.startsWith("http");
if (!isExternalUrl) {
return (
<Paragraph variant={variation.value}>
<Link to={href} reloadDocument className="underline underline-offset-2">
{value}
</Link>
</Paragraph>
);
}
return (
<SimpleTooltip
side="bottom"
button={
<Paragraph variant={variation.value}>
<a href={href} className="underline underline-offset-2" target="_blank">
{value}
<ArrowTopRightOnSquareIcon className="ml-1 inline-block h-4 w-4 text-text-dimmed" />
</a>
</Paragraph>
}
content={href}
/>
);
}
@@ -0,0 +1,54 @@
import { AnimatePresence, useAnimate, usePresence } from "framer-motion";
import { useEffect } from "react";
import { cn } from "~/utils/cn";
type LoadingBarDividerProps = {
isLoading: boolean;
className?: string;
};
export function LoadingBarDivider({ isLoading, className }: LoadingBarDividerProps) {
return (
<div className={cn("relative h-px w-full overflow-hidden bg-grid-bright", className)}>
<AnimationDivider isLoading={isLoading} />
</div>
);
}
export function AnimationDivider({ isLoading }: LoadingBarDividerProps) {
const [scope, animate] = useAnimate();
const [isPresent, safeToRemove] = usePresence();
useEffect(() => {
if (!scope.current) return;
if (isPresent) {
const enterAnimation = async () => {
await animate(
scope.current,
{ left: ["-100%", "100%"], width: "100%" },
{ duration: 2, ease: "easeOut", repeat: Infinity }
);
};
enterAnimation();
} else {
const exitAnimation = async () => {
await animate(scope.current, { opacity: 0 });
safeToRemove();
};
exitAnimation();
}
}, [isPresent, isLoading]);
return (
<AnimatePresence>
{isLoading && (
<div
ref={scope}
className="width-0 absolute left-0 top-0 h-full bg-linear-to-r from-transparent from-5% via-blue-500 to-transparent to-95%"
/>
)}
</AnimatePresence>
);
}
@@ -0,0 +1,28 @@
import type { ReactNode } from "react";
import { createContext, useContext } from "react";
type LocaleContext = {
locales: string[];
};
type LocaleContextProviderProps = {
locales: string[];
children: ReactNode;
};
const Context = createContext<LocaleContext | null>(null);
export const LocaleContextProvider = ({ locales, children }: LocaleContextProviderProps) => {
const value = { locales };
return <Context.Provider value={value}>{children}</Context.Provider>;
};
const throwIfNoProvider = () => {
throw new Error("Please wrap your application in a LocaleContextProvider.");
};
export const useLocales = () => {
const { locales } = useContext(Context) ?? throwIfNoProvider();
return locales;
};
@@ -0,0 +1,161 @@
import { useRef, useState, useLayoutEffect, useCallback } from "react";
import { cn } from "~/utils/cn";
import { SimpleTooltip } from "./Tooltip";
type MiddleTruncateProps = {
text: string;
className?: string;
};
/**
* A component that truncates text in the middle, showing the beginning and end.
* Shows the full text in a tooltip on hover when truncated.
*
* Example: "namespace:category:subcategory:task-name" becomes "namespace:cat…task-name"
*/
export function MiddleTruncate({ text, className }: MiddleTruncateProps) {
const containerRef = useRef<HTMLSpanElement>(null);
const measureRef = useRef<HTMLSpanElement>(null);
const [displayText, setDisplayText] = useState(text);
const [isTruncated, setIsTruncated] = useState(false);
const calculateTruncation = useCallback(() => {
const container = containerRef.current;
const measure = measureRef.current;
if (!container || !measure) return;
const parent = container.parentElement;
if (!parent) return;
// Get the available width from the parent container
const parentStyle = getComputedStyle(parent);
const availableWidth =
parent.clientWidth -
parseFloat(parentStyle.paddingLeft) -
parseFloat(parentStyle.paddingRight);
// Measure full text width
measure.textContent = text;
const fullTextWidth = measure.offsetWidth;
// If text fits, no truncation needed
if (fullTextWidth <= availableWidth) {
setDisplayText(text);
setIsTruncated(false);
return;
}
// Text needs truncation - find optimal split
const ellipsis = "…";
measure.textContent = ellipsis;
const ellipsisWidth = measure.offsetWidth;
const targetWidth = availableWidth - ellipsisWidth - 4; // small buffer
if (targetWidth <= 0) {
setDisplayText(ellipsis);
setIsTruncated(true);
return;
}
// Incrementally find the optimal character counts
let startChars = 0;
let endChars = 0;
// Alternate adding characters from start and end
while (startChars + endChars < text.length) {
// Try adding to start
const testStart = text.slice(0, startChars + 1);
const testEnd = endChars > 0 ? text.slice(-endChars) : "";
measure.textContent = testStart + ellipsis + testEnd;
if (measure.offsetWidth > targetWidth) break;
startChars++;
if (startChars + endChars >= text.length) break;
// Try adding to end
const newTestEnd = text.slice(-(endChars + 1));
measure.textContent = text.slice(0, startChars) + ellipsis + newTestEnd;
if (measure.offsetWidth > targetWidth) break;
endChars++;
}
// Ensure minimum characters on each side for readability
const minChars = 4;
const prevStartChars = startChars;
const prevEndChars = endChars;
if (startChars < minChars && text.length > minChars * 2 + 1) {
startChars = minChars;
}
if (endChars < minChars && text.length > minChars * 2 + 1) {
endChars = minChars;
}
// Re-measure after enforcing minChars to prevent overflow
if (startChars !== prevStartChars || endChars !== prevEndChars) {
measure.textContent = text.slice(0, startChars) + ellipsis + text.slice(-endChars);
if (measure.offsetWidth > targetWidth) {
// Revert to previous values if minChars enforcement causes overflow
startChars = prevStartChars;
endChars = prevEndChars;
}
}
// If combined chars would exceed text length, show full text
if (startChars + endChars >= text.length) {
setDisplayText(text);
setIsTruncated(false);
return;
}
const result = text.slice(0, startChars) + ellipsis + text.slice(-endChars);
setDisplayText(result);
setIsTruncated(true);
}, [text]);
useLayoutEffect(() => {
calculateTruncation();
// Recalculate on resize (guard for jsdom/older browsers)
if (typeof ResizeObserver === "undefined") {
return;
}
const resizeObserver = new ResizeObserver(() => {
calculateTruncation();
});
const container = containerRef.current;
if (container?.parentElement) {
resizeObserver.observe(container.parentElement);
}
return () => {
resizeObserver.disconnect();
};
}, [calculateTruncation]);
const content = (
<span ref={containerRef} className={cn("block", isTruncated && "min-w-[360px]", className)}>
{/* Hidden span for measuring text width */}
<span ref={measureRef} className="invisible absolute whitespace-nowrap" aria-hidden="true" />
{displayText}
</span>
);
if (isTruncated) {
return (
<SimpleTooltip
button={content}
content={<span className="max-w-xs break-all font-mono text-xs">{text}</span>}
side="top"
asChild
/>
);
}
return content;
}
@@ -0,0 +1,31 @@
import type { ReactNode } from "react";
import { createContext, useContext } from "react";
export type OperatingSystemPlatform = "mac" | "windows";
type OperatingSystemContext = {
platform: OperatingSystemPlatform;
};
type OperatingSystemContextProviderProps = {
platform: OperatingSystemPlatform;
children: ReactNode;
};
const Context = createContext<OperatingSystemContext | null>(null);
export const OperatingSystemContextProvider = ({
platform,
children,
}: OperatingSystemContextProviderProps) => {
return <Context.Provider value={{ platform }}>{children}</Context.Provider>;
};
const throwIfNoProvider = () => {
throw new Error("Please wrap your application in an OperatingSystemContextProvider.");
};
export const useOperatingSystem = () => {
const { platform } = useContext(Context) ?? throwIfNoProvider();
return { platform };
};
@@ -0,0 +1,80 @@
import { Link, useNavigation } from "@remix-run/react";
import { type ReactNode } from "react";
import { QuestionMarkIcon } from "~/assets/icons/QuestionMarkIcon";
import { OrgBanner } from "../billing/OrgBanner";
import { BreadcrumbIcon } from "./BreadcrumbIcon";
import { Header2 } from "./Headers";
import { LoadingBarDivider } from "./LoadingBarDivider";
import { SimpleTooltip } from "./Tooltip";
import { DashboardAgentLauncher } from "../dashboard-agent/dashboardAgentLauncher";
type WithChildren = {
children: React.ReactNode;
className?: string;
};
export function NavBar({ children }: WithChildren) {
const navigation = useNavigation();
const isLoading = navigation.state === "loading" || navigation.state === "submitting";
return (
<div>
<div className="grid h-10 w-full grid-rows-[auto_1px] bg-background-bright">
<div className="flex w-full items-center gap-2 pl-3 pr-2">
<div className="flex flex-1 items-center justify-between">{children}</div>
<DashboardAgentLauncher />
</div>
<LoadingBarDivider isLoading={isLoading} />
</div>
<OrgBanner />
</div>
);
}
type PageTitleProps = {
title: ReactNode;
backButton?: {
to: string;
text: string;
};
/**
* Renders to the right of the title.
* - Pass a string → a question-mark icon with the string as its hover tooltip.
* - Pass a ReactNode → rendered verbatim, for custom adornments.
*/
accessory?: ReactNode;
};
export function PageTitle({ title, backButton, accessory }: PageTitleProps) {
return (
<div className="flex items-center gap-1">
{backButton && (
<div className="group -ml-1.5 flex items-center gap-0">
<Link
to={backButton.to}
className="rounded px-1.5 py-1 text-xs text-text-dimmed transition focus-custom group-hover:bg-background-raised group-hover:text-text-bright"
>
{backButton.text}
</Link>
<BreadcrumbIcon className="h-5" />
</div>
)}
<Header2 className="flex items-center gap-1">{title}</Header2>
{accessory !== undefined &&
(typeof accessory === "string" ? (
<SimpleTooltip
button={<QuestionMarkIcon className="size-4 text-text-dimmed" />}
content={accessory}
className="max-w-xs"
disableHoverableContent
/>
) : (
accessory
))}
</div>
);
}
export function PageAccessories({ children }: WithChildren) {
return <div className="flex items-center gap-2">{children}</div>;
}
@@ -0,0 +1,238 @@
import { ChevronRightIcon } from "@heroicons/react/24/outline";
import { ChevronLeftIcon } from "@heroicons/react/24/solid";
import { Link, useLocation } from "@remix-run/react";
import { cn } from "~/utils/cn";
import { LinkButton } from "./Buttons";
/** Pass `hasNextPage` when the total page count is unknown; use `showPageNumbers={false}` in that case. */
export function PaginationControls({
currentPage,
totalPages,
hasNextPage,
showPageNumbers = true,
}: {
currentPage: number;
totalPages: number;
/** When set, navigation uses this instead of `totalPages`. */
hasNextPage?: boolean;
showPageNumbers?: boolean;
}) {
const location = useLocation();
const hasNextMode = hasNextPage !== undefined;
const showPagination = hasNextMode ? currentPage > 1 || hasNextPage : totalPages > 1;
const nextDisabled = hasNextMode ? !hasNextPage : currentPage === totalPages;
if (!showPagination) {
return null;
}
return (
<nav className="flex items-center gap-1" aria-label="Pagination">
{showPageNumbers ? (
<>
<LinkButton
to={pageUrl(location, currentPage - 1)}
variant="secondary/small"
LeadingIcon={ChevronLeftIcon}
shortcut={{ key: "j" }}
tooltip="Previous"
disabled={currentPage === 1}
className={cn("px-2", currentPage > 1 ? "group" : "")}
/>
{calculatePageLinks(currentPage, totalPages).map((page, i) => (
<PageLinkComponent page={page} key={i} location={location} />
))}
<LinkButton
to={pageUrl(location, currentPage + 1)}
variant="secondary/small"
TrailingIcon={ChevronRightIcon}
shortcut={{ key: "k" }}
tooltip="Next"
disabled={nextDisabled}
className={cn("px-2", !nextDisabled ? "group" : "")}
/>
</>
) : (
<div className="flex items-center">
<div className={cn("peer/prev order-1", currentPage === 1 && "pointer-events-none")}>
<LinkButton
to={pageUrl(location, currentPage - 1)}
variant="secondary/small"
LeadingIcon={ChevronLeftIcon}
shortcut={{ key: "j" }}
tooltip="Previous"
disabled={currentPage === 1}
className={cn(
"flex items-center rounded-r-none border-r-0 pl-2 pr-2.25",
currentPage === 1 && "cursor-not-allowed opacity-50"
)}
/>
</div>
<div
className={cn(
"order-2 h-6 w-px bg-surface-control transition-colors peer-hover/next:bg-surface-control-hover peer-hover/prev:bg-surface-control-hover",
currentPage === 1 && nextDisabled && "opacity-30"
)}
/>
<div className={cn("peer/next order-3", nextDisabled && "pointer-events-none")}>
<LinkButton
to={pageUrl(location, currentPage + 1)}
variant="secondary/small"
TrailingIcon={ChevronRightIcon}
shortcut={{ key: "k" }}
tooltip="Next"
disabled={nextDisabled}
className={cn(
"flex items-center rounded-l-none border-l-0 pl-2.25 pr-2",
nextDisabled && "cursor-not-allowed opacity-50"
)}
/>
</div>
</div>
)}
</nav>
);
}
function pageUrl(location: ReturnType<typeof useLocation>, page: number): string {
const search = new URLSearchParams(location.search);
search.set("page", String(page));
return location.pathname + "?" + search.toString();
}
const baseClass =
"flex items-center justify-center border border-transparent min-w-6 h-6 px-1 text-xs font-medium transition text-text-dimmed rounded-sm focus-visible:focus-custom";
const unselectedClass = "hover:bg-tertiary hover:text-text-bright";
const selectedClass =
"border-border-bright bg-tertiary text-text-bright hover:bg-surface-control/50";
function PageLinkComponent({
page,
location,
}: {
page: PageLink;
location: ReturnType<typeof useLocation>;
}) {
if (page.type === "specific") {
return (
<Link
to={pageUrl(location, page.page)}
className={cn(baseClass, page.isCurrent ? selectedClass : unselectedClass)}
>
{page.page}
</Link>
);
} else {
return <span className={baseClass}>...</span>;
}
}
type PageLink = EllipsisPageLink | SpecificPageLink;
type EllipsisPageLink = {
type: "ellipses";
};
type SpecificPageLink = {
type: "specific";
page: number;
isCurrent: boolean;
};
// If there are less than or equal to 6 pages, just show all the pages.
// If there are more than 5 pages, show the first 3, the current page, and the last 3.
function calculatePageLinks(currentPage: number, totalPages: number): Array<PageLink> {
const pageLinks: Array<PageLink> = [];
if (totalPages <= 10) {
for (let i = 1; i <= totalPages; i++) {
pageLinks.push({
type: "specific",
page: i,
isCurrent: i === currentPage,
});
}
} else {
if (currentPage <= 3) {
for (let i = 1; i <= 4; i++) {
pageLinks.push({
type: "specific",
page: i,
isCurrent: i === currentPage,
});
}
pageLinks.push({
type: "ellipses",
});
for (let i = totalPages - 3; i <= totalPages; i++) {
pageLinks.push({
type: "specific",
page: i,
isCurrent: i === currentPage,
});
}
} else if (currentPage >= totalPages - 3) {
for (let i = 1; i <= 3; i++) {
pageLinks.push({
type: "specific",
page: i,
isCurrent: i === currentPage,
});
}
pageLinks.push({
type: "ellipses",
});
for (let i = totalPages - 4; i <= totalPages; i++) {
pageLinks.push({
type: "specific",
page: i,
isCurrent: i === currentPage,
});
}
} else {
for (let i = 1; i <= 3; i++) {
pageLinks.push({
type: "specific",
page: i,
isCurrent: i === currentPage,
});
}
pageLinks.push({
type: "ellipses",
});
for (let i = currentPage - 1; i <= currentPage + 1; i++) {
pageLinks.push({
type: "specific",
page: i,
isCurrent: i === currentPage,
});
}
pageLinks.push({
type: "ellipses",
});
for (let i = totalPages - 2; i <= totalPages; i++) {
pageLinks.push({
type: "specific",
page: i,
isCurrent: i === currentPage,
});
}
}
}
return pageLinks;
}
@@ -0,0 +1,107 @@
import { cn } from "~/utils/cn";
const paragraphVariants = {
base: {
text: "font-sans text-base font-medium text-text-dimmed",
spacing: "mb-3",
},
"base/bright": {
text: "font-sans text-base font-medium text-text-bright",
spacing: "mb-3",
},
small: {
text: "font-sans text-sm font-medium text-text-dimmed",
spacing: "mb-2",
},
"small/bright": {
text: "font-sans text-sm font-medium text-text-bright",
spacing: "mb-2",
},
"small/dimmed": {
text: "font-sans text-sm font-medium text-text-dimmed",
spacing: "mb-2",
},
"extra-small": {
text: "font-sans text-xs font-medium text-text-dimmed",
spacing: "mb-1.5",
},
"extra-small/bright": {
text: "font-sans text-xs font-medium text-text-bright",
spacing: "mb-1.5",
},
"extra-small/dimmed": {
text: "font-sans text-xs font-medium text-text-dimmed",
spacing: "mb-1.5",
},
"extra-small/dimmed/mono": {
text: "font-mono text-xs font-medium text-text-dimmed",
spacing: "mb-1.5",
},
"extra-small/mono": {
text: "font-mono text-xs font-medium text-text-dimmed",
spacing: "mb-1.5",
},
"extra-small/bright/mono": {
text: "font-mono text-xs text-text-bright",
spacing: "mb-1.5",
},
"extra-small/caps": {
text: "font-sans text-xs uppercase tracking-wider font-medium text-text-dimmed",
spacing: "mb-1.5",
},
"extra-small/bright/caps": {
text: "font-sans text-xs uppercase tracking-wider font-medium text-text-bright",
spacing: "mb-1.5",
},
"extra-extra-small": {
text: "font-sans text-xxs font-medium text-text-dimmed",
spacing: "mb-1",
},
"extra-extra-small/bright": {
text: "font-sans text-xxs font-medium text-text-bright",
spacing: "mb-1",
},
"extra-extra-small/caps": {
text: "font-sans text-xxs uppercase tracking-wider font-medium text-text-dimmed",
spacing: "mb-1",
},
"extra-extra-small/bright/caps": {
text: "font-sans text-xxs uppercase tracking-wider font-medium text-text-bright",
spacing: "mb-1",
},
"extra-extra-small/dimmed/caps": {
text: "font-sans text-xxs uppercase tracking-wider font-medium text-text-dimmed",
spacing: "mb-1",
},
};
export type ParagraphVariant = keyof typeof paragraphVariants;
type ParagraphProps = {
variant?: ParagraphVariant;
className?: string;
spacing?: boolean;
children: React.ReactNode;
} & React.HTMLAttributes<HTMLParagraphElement>;
export function Paragraph({
variant = "base",
className,
spacing = false,
children,
...props
}: ParagraphProps) {
return (
<p
className={cn(
paragraphVariants[variant].text,
spacing === true && paragraphVariants[variant].spacing,
className
)}
{...props}
>
{children}
</p>
);
}
@@ -0,0 +1,36 @@
import { forwardRef, type ReactNode } from "react";
import { Button } from "./Buttons";
export const DEFAULT_NO_PERMISSION_TOOLTIP = "You don't have permission to do this";
type PermissionButtonProps = React.ComponentProps<typeof Button> & {
/** Server-computed flag (see `checkPermissions`). When false the button is disabled with a tooltip. */
hasPermission: boolean;
noPermissionTooltip?: ReactNode;
};
/**
* A `Button` that disables itself and shows an explanatory tooltip when the
* user lacks permission. Display only — the server route builder's
* `authorization` block is the real gate. `Button` already renders its
* `tooltip` while disabled (it wraps the disabled button in a hoverable span),
* so we reuse that path.
*/
export const PermissionButton = forwardRef<HTMLButtonElement, PermissionButtonProps>(
({ hasPermission, noPermissionTooltip, disabled, tooltip, ...props }, ref) => {
if (hasPermission) {
return <Button ref={ref} disabled={disabled} tooltip={tooltip} {...props} />;
}
return (
<Button
ref={ref}
{...props}
disabled
tooltip={noPermissionTooltip ?? DEFAULT_NO_PERMISSION_TOOLTIP}
/>
);
}
);
PermissionButton.displayName = "PermissionButton";
@@ -0,0 +1,43 @@
import { type ReactNode } from "react";
import { cn } from "~/utils/cn";
import { ButtonContent, type ButtonContentPropsType, LinkButton } from "./Buttons";
import { SimpleTooltip } from "./Tooltip";
import { DEFAULT_NO_PERMISSION_TOOLTIP } from "./PermissionButton";
type PermissionLinkProps = React.ComponentProps<typeof LinkButton> & {
/** Server-computed flag (see `checkPermissions`). When false the link is disabled with a tooltip. */
hasPermission: boolean;
noPermissionTooltip?: ReactNode;
};
/**
* A `LinkButton` that disables itself and shows an explanatory tooltip when the
* user lacks permission. Display only — the server route builder's
* `authorization` block is the real gate. Unlike `Button`, `LinkButton` has no
* tooltip support and renders a `pointer-events-none` element when disabled
* (which can't be hovered), so the denied state renders a `SimpleTooltip`
* around a non-interactive `ButtonContent` instead — the same pattern the team
* settings page uses for its gated controls.
*/
export function PermissionLink({
hasPermission,
noPermissionTooltip,
...props
}: PermissionLinkProps) {
if (hasPermission) {
return <LinkButton {...props} />;
}
return (
<SimpleTooltip
button={
<ButtonContent
{...(props as ButtonContentPropsType)}
className={cn(props.className, "cursor-not-allowed opacity-50")}
/>
}
content={noPermissionTooltip ?? DEFAULT_NO_PERMISSION_TOOLTIP}
disableHoverableContent
/>
);
}
@@ -0,0 +1,322 @@
"use client";
import { CheckIcon } from "@heroicons/react/20/solid";
import { EllipsisVerticalIcon } from "@heroicons/react/24/solid";
import * as PopoverPrimitive from "@radix-ui/react-popover";
import { Link } from "@remix-run/react";
import * as React from "react";
import { DropdownIcon } from "~/assets/icons/DropdownIcon";
import * as useShortcutKeys from "~/hooks/useShortcutKeys";
import { cn } from "~/utils/cn";
import { ButtonContent, type ButtonContentPropsType } from "./Buttons";
import { type RenderIcon } from "./Icon";
import { Paragraph, type ParagraphVariant } from "./Paragraph";
import { ShortcutKey } from "./ShortcutKey";
const Popover = PopoverPrimitive.Root;
const PopoverTrigger = PopoverPrimitive.Trigger;
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
avoidCollisions={true}
className={cn(
"z-50 min-w-max rounded border border-grid-bright bg-background-bright p-4 shadow-md outline-hidden animate-in data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
style={{
maxHeight: "var(--radix-popover-content-available-height)",
}}
{...props}
/>
</PopoverPrimitive.Portal>
));
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
function PopoverSectionHeader({
title,
variant = "extra-small",
}: {
title: string;
variant?: ParagraphVariant;
}) {
return (
<Paragraph variant={variant} className="bg-background-hover px-2.5 py-1.5">
{title}
</Paragraph>
);
}
const PopoverMenuItem = React.forwardRef<
HTMLButtonElement | HTMLAnchorElement,
{
to?: string;
icon?: RenderIcon;
title: React.ReactNode;
isSelected?: boolean;
variant?: ButtonContentPropsType;
leadingIconClassName?: string;
className?: string;
onClick?: React.MouseEventHandler;
disabled?: boolean;
openInNewTab?: boolean;
name?: string;
value?: string;
type?: React.ComponentProps<"button">["type"];
danger?: boolean;
}
>(
(
{
to,
icon,
title,
isSelected,
variant = { variant: "small-menu-item" },
leadingIconClassName,
className,
onClick,
disabled,
openInNewTab = false,
name,
value,
type,
danger = false,
},
ref
) => {
const contentProps = {
variant: variant.variant,
LeadingIcon: icon,
leadingIconClassName: danger
? cn(leadingIconClassName, "transition-colors group-hover/button:text-error")
: leadingIconClassName,
fullWidth: true,
textAlignLeft: true,
TrailingIcon: isSelected ? CheckIcon : undefined,
className: cn(
danger
? "transition-colors group-hover/button:bg-error/10 group-hover/button:text-error [&_span]:transition-colors group-hover/button:[&_span]:text-error"
: "group-hover:bg-background-raised",
isSelected ? "bg-background-hover group-hover:bg-surface-control/50" : undefined,
className
),
} as const;
if (to) {
return (
<Link
to={to}
ref={ref as React.Ref<HTMLAnchorElement>}
className={cn("group/button focus-custom", contentProps.fullWidth ? "w-full" : "")}
onClick={onClick as any}
target={openInNewTab ? "_blank" : undefined}
rel={openInNewTab ? "noopener noreferrer" : undefined}
>
<ButtonContent {...contentProps}>{title}</ButtonContent>
</Link>
);
}
return (
<button
ref={ref as React.Ref<HTMLButtonElement>}
onClick={onClick}
disabled={disabled}
className={cn(
"group/button outline-hidden focus-custom",
contentProps.fullWidth ? "w-full" : ""
)}
name={name}
value={value}
type={type ?? "button"}
>
<ButtonContent {...contentProps}>{title}</ButtonContent>
</button>
);
}
);
PopoverMenuItem.displayName = "PopoverMenuItem";
function PopoverCustomTrigger({
isOpen,
children,
className,
...props
}: { isOpen?: boolean } & React.ComponentPropsWithoutRef<typeof PopoverTrigger>) {
return (
<PopoverTrigger
{...props}
className={cn(
"group flex items-center justify-end gap-1 rounded text-text-dimmed transition focus-custom hover:bg-background-dimmed hover:text-text-bright",
className
)}
>
{children}
</PopoverTrigger>
);
}
function PopoverSideMenuTrigger({
isOpen,
children,
className,
shortcut,
hideShortcutKey = false,
...props
}: {
isOpen?: boolean;
shortcut?: useShortcutKeys.ShortcutDefinition;
hideShortcutKey?: boolean;
} & React.ComponentPropsWithoutRef<typeof PopoverTrigger>) {
const ref = React.useRef<HTMLButtonElement>(null);
useShortcutKeys.useShortcutKeys({
shortcut: shortcut,
action: (e) => {
e.preventDefault();
e.stopPropagation();
if (ref.current) {
ref.current.click();
}
},
});
return (
<PopoverTrigger
{...props}
ref={ref}
className={cn(
"flex h-[1.8rem] shrink-0 select-none items-center rounded-sm bg-transparent pl-[0.4rem] pr-2.5 text-center font-sans text-2sm font-normal text-text-bright transition duration-150 focus-custom hover:bg-background-hover",
shortcut && !hideShortcutKey ? "justify-between gap-x-1.5" : "",
className
)}
>
{children}
{shortcut && !hideShortcutKey && (
<ShortcutKey className="size-4 flex-none" shortcut={shortcut} variant={"small"} />
)}
</PopoverTrigger>
);
}
const popoverArrowTriggerVariants = {
minimal: {
trigger: "text-text-dimmed hover:bg-background-raised hover:text-text-bright",
text: "group-hover:text-text-bright",
icon: "text-text-dimmed group-hover:text-text-bright",
},
primary: {
trigger:
"bg-indigo-600 border border-indigo-500 text-text-bright hover:bg-indigo-500 hover:border-indigo-400 disabled:opacity-50 disabled:pointer-events-none",
text: "text-text-bright hover:text-white",
icon: "text-text-bright",
},
secondary: {
trigger:
"bg-secondary border border-border-bright text-text-bright hover:bg-surface-control hover:border-border-brighter disabled:opacity-60 disabled:pointer-events-none",
text: "text-text-bright",
icon: "text-text-bright",
},
tertiary: {
trigger: "bg-tertiary text-text-bright hover:bg-surface-control",
text: "text-text-bright",
icon: "text-text-bright",
},
} as const;
type PopoverArrowTriggerVariant = keyof typeof popoverArrowTriggerVariants;
function PopoverArrowTrigger({
isOpen,
children,
fullWidth = false,
overflowHidden = false,
variant = "minimal",
className,
...props
}: {
isOpen?: boolean;
fullWidth?: boolean;
overflowHidden?: boolean;
variant?: PopoverArrowTriggerVariant;
} & React.ComponentPropsWithoutRef<typeof PopoverTrigger>) {
const variantStyles = popoverArrowTriggerVariants[variant];
return (
<PopoverTrigger
{...props}
className={cn(
"group flex h-6 items-center gap-1 rounded pl-2 pr-1 transition focus-custom",
variantStyles.trigger,
fullWidth && "w-full justify-between",
className
)}
>
<Paragraph
variant="extra-small"
className={cn("flex transition", variantStyles.text, overflowHidden && "overflow-hidden")}
>
{children}
</Paragraph>
<DropdownIcon className={cn("size-4 min-w-4 transition", variantStyles.icon)} />
</PopoverTrigger>
);
}
const popoverVerticalEllipseVariants = {
minimal: {
trigger: "size-6 rounded-[3px] text-text-dimmed hover:bg-tertiary hover:text-text-bright",
icon: "size-5",
},
secondary: {
trigger:
"size-6 rounded border border-border-bright bg-secondary text-text-bright hover:bg-surface-control hover:border-border-brighter",
icon: "size-4",
},
} as const;
type PopoverVerticalEllipseVariant = keyof typeof popoverVerticalEllipseVariants;
function PopoverVerticalEllipseTrigger({
isOpen,
variant = "minimal",
className,
...props
}: {
isOpen?: boolean;
variant?: PopoverVerticalEllipseVariant;
} & React.ComponentPropsWithoutRef<typeof PopoverTrigger>) {
const styles = popoverVerticalEllipseVariants[variant];
return (
<PopoverTrigger
{...props}
className={cn(
"group flex items-center justify-center transition focus-custom",
styles.trigger,
className
)}
>
<EllipsisVerticalIcon className={cn(styles.icon, "transition")} />
</PopoverTrigger>
);
}
export {
Popover,
PopoverArrowTrigger,
PopoverContent,
PopoverCustomTrigger,
PopoverMenuItem,
PopoverSectionHeader,
PopoverSideMenuTrigger,
PopoverTrigger,
PopoverVerticalEllipseTrigger,
};
export type { PopoverArrowTriggerVariant };
@@ -0,0 +1,40 @@
// Formats duration in a human readable way, some examples:
// 1h 30m
// 1m 30s
// 1h
// Uses built-in plain Date object, so it's not timezone aware
export function PrettyDuration({
startAt,
endAt,
fallback,
}: {
startAt?: Date | null;
endAt?: Date | null;
fallback?: string;
}) {
if (!startAt || !endAt) {
return <>{fallback ?? "-"}</>;
}
const duration = Math.abs(endAt.getTime() - startAt.getTime());
const hours = Math.floor(duration / (1000 * 60 * 60));
const minutes = Math.floor((duration / (1000 * 60)) % 60);
const seconds = Math.floor((duration / 1000) % 60);
const durationParts = [];
if (hours > 0) {
durationParts.push(`${hours}h`);
}
if (minutes > 0) {
durationParts.push(`${minutes}m`);
}
if (seconds > 0) {
durationParts.push(`${seconds}s`);
}
return <>{durationParts.join(" ")}</>;
}
@@ -0,0 +1,30 @@
import { type ReactNode } from "react";
import { cn } from "~/utils/cn";
type ChildrenClassName = {
children: ReactNode;
className?: string;
};
function PropertyTable({ children, className }: { children: ReactNode; className?: string }) {
return <div className={cn("flex flex-col gap-y-3", className)}>{children}</div>;
}
function PropertyItem({ children, className }: ChildrenClassName) {
return <div className={cn("flex flex-col gap-0 text-sm", className)}>{children}</div>;
}
function PropertyLabel({ children, className }: ChildrenClassName) {
return <div className={cn("font-medium text-text-bright", className)}>{children}</div>;
}
function PropertyValue({ children, className }: ChildrenClassName) {
return <div className={cn("text-text-dimmed", className)}>{children}</div>;
}
export {
PropertyItem as Item,
PropertyLabel as Label,
PropertyTable as Table,
PropertyValue as Value,
};
@@ -0,0 +1,23 @@
import { cn } from "~/utils/cn";
export function PulsingDot({
className,
ringClassName,
dotClassName,
}: {
className?: string;
ringClassName?: string;
dotClassName?: string;
}) {
return (
<span className={cn("relative flex size-2", className)}>
<span
className={cn(
"absolute h-full w-full animate-ping rounded-full border border-blue-500 opacity-100 duration-1000",
ringClassName
)}
/>
<span className={cn("size-2 rounded-full bg-blue-500", dotClassName)} />
</span>
);
}
@@ -0,0 +1,165 @@
import * as React from "react";
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group";
import { Circle } from "lucide-react";
import { cn } from "~/utils/cn";
import { Badge } from "./Badge";
import { Paragraph } from "./Paragraph";
const variants = {
"simple/small": {
button: "w-fit pr-4 data-disabled:opacity-70",
label: "text-sm text-text-bright mt-0.5 select-none",
description: "text-text-dimmed",
inputPosition: "mt-1",
icon: "w-8 h-8 mb-2",
},
simple: {
button: "w-fit pr-4 data-disabled:opacity-70",
label: "text-text-bright select-none",
description: "text-text-dimmed",
inputPosition: "mt-1",
icon: "w-8 h-8 mb-2",
},
"button/small": {
button:
"flex items-center w-fit h-8 pl-2 pr-3 rounded-md border hover:data-[state=checked]:border-border-bright border-border-bright hover:border-border-bright transition data-disabled:opacity-70 data-disabled:hover:bg-transparent hover:data-[state=checked]:bg-white/4 data-[state=checked]:bg-white/4",
label: "text-sm text-text-bright select-none",
description: "text-text-dimmed",
inputPosition: "mt-0",
icon: "w-8 h-8 mb-2",
},
button: {
button:
"w-fit py-2 pl-3 pr-4 rounded border border-border-bright hover:bg-background-dimmed hover:border-border-brightest transition data-[state=checked]:bg-background-dimmed data-disabled:opacity-70",
label: "text-text-bright select-none",
description: "text-text-dimmed",
inputPosition: "mt-1",
icon: "w-8 h-8 mb-2",
},
description: {
button:
"w-full p-2.5 hover:data-[state=checked]:bg-white/4 data-[state=checked]:bg-white/4 transition data-disabled:opacity-70 hover:border-border-bright border-border-bright hover:data-[state=checked]:border-border-bright border rounded-md",
label: "text-text-bright font-semibold -mt-0.5 text-left text-sm",
description: "text-text-dimmed mt-0 text-left",
inputPosition: "mt-0",
icon: "w-8 h-8 mb-2",
},
icon: {
button:
"w-full p-2.5 pb-4 hover:bg-background-dimmed transition data-disabled:opacity-70 data-[state=checked]:bg-background-dimmed border-border-bright border rounded-sm",
label: "text-text-bright font-semibold -mt-1 text-left",
description: "text-text-dimmed mt-0 text-left",
inputPosition: "mt-0",
icon: "mb-3",
},
};
type RadioButtonCircleProps = {
checked: boolean;
boxClassName?: string;
outerCircleClassName?: string;
innerCircleClassName?: string;
};
export function RadioButtonCircle({
checked,
boxClassName,
outerCircleClassName,
innerCircleClassName,
}: RadioButtonCircleProps) {
return (
<div
className={cn(
"ring-offset-background aspect-square h-4 w-4 shrink-0 overflow-hidden rounded-full border border-border-bright bg-background-raised focus-custom disabled:cursor-not-allowed disabled:opacity-50",
boxClassName
)}
>
{checked && (
<div
className={cn(
"flex h-full w-full items-center justify-center border border-indigo-500 bg-indigo-600",
outerCircleClassName
)}
>
<Circle
className={cn("size-2 fill-text-bright text-text-bright", innerCircleClassName)}
/>
</div>
)}
</div>
);
}
export const RadioGroup = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
>(({ className, ...props }, ref) => {
return <RadioGroupPrimitive.Root className={className} {...props} ref={ref} />;
});
type RadioGroupItemProps = Omit<
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>,
"onChange"
> & {
variant?: keyof typeof variants;
label?: React.ReactNode;
description?: React.ReactNode;
badges?: string[];
className?: string;
icon?: React.ReactNode;
};
export const RadioGroupItem = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Item>,
RadioGroupItemProps
>(
(
{ className, children, variant = "simple", label, description, badges, icon, ...props },
ref
) => {
const variation = variants[variant];
return (
<RadioGroupPrimitive.Item
ref={ref}
className={cn(
"group flex cursor-pointer items-start gap-x-2 transition focus-custom",
variation.button,
className
)}
{...props}
>
<div
className={cn(
"ring-offset-background focus-visible:ring-ring aspect-square h-4 w-4 shrink-0 overflow-hidden rounded-full border border-border-bright focus-custom group-data-[state=checked]:border-indigo-500 disabled:cursor-not-allowed disabled:opacity-50",
variation.inputPosition
)}
>
<RadioGroupPrimitive.Indicator className="flex h-full w-full items-center justify-center rounded-full bg-indigo-600">
<Circle className="size-2 fill-text-bright text-text-bright" />
</RadioGroupPrimitive.Indicator>
</div>
<div className={cn(icon ? "flex h-full flex-col justify-end" : "")}>
{variant === "icon" && <div className={variation.icon}>{icon}</div>}
<div className="flex items-center gap-x-2">
<label htmlFor={props.id} className={cn("cursor-pointer", variation.label)}>
{label}
</label>
{badges && (
<span className="-mr-2 flex gap-x-1.5">
{badges.map((badge) => (
<Badge key={badge}>{badge}</Badge>
))}
</span>
)}
</div>
{(variant === "description" || variant === "icon") && (
<Paragraph variant="small" className={cn("mt-0.5", variation.description)}>
{description}
</Paragraph>
)}
</div>
</RadioGroupPrimitive.Item>
);
}
);
@@ -0,0 +1,102 @@
"use client";
import React, { useRef } from "react";
import { PanelGroup, Panel, PanelResizer } from "@window-splitter/react";
import { cn } from "~/utils/cn";
const ResizablePanelGroup = ({ className, ...props }: React.ComponentProps<typeof PanelGroup>) => (
<PanelGroup
className={cn(
"flex w-full overflow-hidden data-[panel-group-direction=vertical]:flex-col",
className
)}
{...props}
/>
);
const ResizablePanel = Panel;
const ResizableHandle = ({
withHandle = true,
className,
...props
}: React.ComponentProps<typeof PanelResizer> & {
withHandle?: boolean;
}) => (
<PanelResizer
onMouseDown={(e: React.MouseEvent) => {
e.preventDefault();
}}
className={cn(
"group relative flex items-center justify-center focus-custom",
// Horizontal size
"w-0.75",
// Vertical size
"data-[handle-orientation=vertical]:h-0.75 data-[handle-orientation=vertical]:w-full",
// Normal-state line (::before) — 1px, centered in the 3px handle
"before:absolute before:left-px before:top-0 before:h-full before:w-px before:bg-grid-bright",
"data-[handle-orientation=vertical]:before:left-0 data-[handle-orientation=vertical]:before:top-px data-[handle-orientation=vertical]:before:h-px data-[handle-orientation=vertical]:before:w-full",
// Hit area (::after pseudo) for easier grabbing
"after:absolute after:inset-y-0 after:left-1/2 after:w-3 after:-translate-x-1/2",
"data-[handle-orientation=vertical]:after:inset-x-0 data-[handle-orientation=vertical]:after:inset-y-auto",
"data-[handle-orientation=vertical]:after:left-0 data-[handle-orientation=vertical]:after:top-1/2",
"data-[handle-orientation=vertical]:after:h-3 data-[handle-orientation=vertical]:after:w-full",
"data-[handle-orientation=vertical]:after:-translate-y-1/2 data-[handle-orientation=vertical]:after:translate-x-0",
className
)}
size="3px"
{...props}
>
{/* Indigo hover overlay — absolutely positioned on top of everything */}
<div className="pointer-events-none absolute left-0 top-0 z-10 h-full w-0.75 bg-indigo-500 opacity-0 transition-opacity group-hover:opacity-100 group-data-[handle-orientation=vertical]:hidden" />
<div className="pointer-events-none absolute left-0 top-0 z-10 hidden h-0.75 w-full bg-indigo-500 opacity-0 transition-opacity group-hover:opacity-100 group-data-[handle-orientation=vertical]:block" />
{withHandle && (
<>
{/* Horizontal orientation dots (vertical arrangement) */}
<div className="relative z-1 flex h-5 w-0.75 flex-col items-center justify-center gap-0.75 bg-background-dimmed transition-opacity group-hover:opacity-0 group-data-[handle-orientation=vertical]:hidden">
{Array.from({ length: 3 }).map((_, index) => (
<div key={index} className="h-[0.1875rem] w-0.75 rounded-full bg-surface-control" />
))}
</div>
{/* Vertical orientation dots (horizontal arrangement) */}
<div className="relative z-1 hidden h-0.75 w-5 flex-row items-center justify-center gap-0.75 bg-background-dimmed transition-opacity group-hover:opacity-0 group-data-[handle-orientation=vertical]:flex">
{Array.from({ length: 3 }).map((_, index) => (
<div key={index} className="h-0.75 w-[0.1875rem] rounded-full bg-surface-control" />
))}
</div>
</>
)}
</PanelResizer>
);
// react-window-splitter drives the collapse animation through @react-spring/rafz,
// which has timing/interaction issues with Firefox that produce visual glitches
// (alternating frames, panels stuck at min, panelHasSpace invariant violations).
// Disable the animation on Firefox; it works correctly in Chromium and Safari.
const RESIZABLE_PANEL_ANIMATION =
typeof navigator !== "undefined" && /firefox/i.test(navigator.userAgent)
? undefined
: ({ easing: "ease-in-out", duration: 300 } as const);
const COLLAPSIBLE_HANDLE_CLASSNAME = "transition-opacity duration-200";
function collapsibleHandleClassName(show: boolean) {
return cn(COLLAPSIBLE_HANDLE_CLASSNAME, !show && "pointer-events-none opacity-0");
}
function useFrozenValue<T>(value: T | null | undefined): T | null | undefined {
const ref = useRef(value);
if (value != null) ref.current = value;
return ref.current;
}
export {
RESIZABLE_PANEL_ANIMATION,
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
collapsibleHandleClassName,
useFrozenValue,
};
export type ResizableSnapshot = React.ComponentProps<typeof PanelGroup>["snapshot"];
@@ -0,0 +1,178 @@
import { MagnifyingGlassIcon, XMarkIcon } from "@heroicons/react/20/solid";
import { motion } from "framer-motion";
import { useEffect, useRef, useState } from "react";
import { Input } from "~/components/primitives/Input";
import { ShortcutKey } from "~/components/primitives/ShortcutKey";
import { SimpleTooltip } from "~/components/primitives/Tooltip";
import { useSearchParams } from "~/hooks/useSearchParam";
import { cn } from "~/utils/cn";
export type SearchInputProps = {
placeholder?: string;
/** The URL search param name to read/write. Defaults to "search". */
paramName?: string;
/** Additional URL params to reset when searching or clearing (e.g. pagination). Defaults to ["cursor", "direction"]. */
resetParams?: string[];
autoFocus?: boolean;
/**
* Controlled value. When provided alongside `onValueChange`, the input
* skips URL params entirely and acts as a controlled component — useful
* for client-side filters (e.g. tree filtering) that don't round-trip
* to the server.
*/
value?: string;
/**
* Called on every keystroke and on clear when in controlled mode. The
* presence of this prop is what switches the input into controlled
* mode; `paramName`/`resetParams` are ignored when it's set.
*/
onValueChange?: (value: string) => void;
};
export function SearchInput({
placeholder = "Search logs…",
paramName = "search",
resetParams = ["cursor", "direction"],
autoFocus,
value: controlledValue,
onValueChange,
}: SearchInputProps) {
const inputRef = useRef<HTMLInputElement>(null);
const { value, replace, del } = useSearchParams();
const isControlled = onValueChange !== undefined;
const initialSearch = isControlled ? (controlledValue ?? "") : (value(paramName) ?? "");
const [text, setText] = useState(initialSearch);
const [isFocused, setIsFocused] = useState(false);
// Compare against a ref, not `text`, so the effect stays off the keystroke path.
// Trade-off: controlled mode assumes the parent accepts onValueChange; it won't
// re-sync `text` if the parent rejects a change and holds `value` unchanged.
const lastSyncedRef = useRef(initialSearch);
useEffect(() => {
if (isControlled) {
if (controlledValue !== undefined && controlledValue !== lastSyncedRef.current) {
lastSyncedRef.current = controlledValue;
setText(controlledValue);
}
return;
}
const urlSearch = value(paramName) ?? "";
if (urlSearch === lastSyncedRef.current) return;
// Only mark synced once we actually apply it, so a URL change during focus still syncs on blur.
if (!isFocused) {
lastSyncedRef.current = urlSearch;
setText(urlSearch);
}
}, [isControlled, controlledValue, value, isFocused, paramName]);
const updateText = (next: string) => {
setText(next);
if (isControlled) {
onValueChange?.(next);
}
};
const handleSubmit = () => {
if (isControlled) {
// Live updates already fired through onValueChange; submit is a no-op.
return;
}
const resetValues = Object.fromEntries(resetParams.map((p) => [p, undefined]));
if (text.trim()) {
replace({ [paramName]: text.trim(), ...resetValues });
} else {
del([paramName, ...resetParams]);
}
};
const handleClear = () => {
setText("");
if (isControlled) {
onValueChange?.("");
} else {
del([paramName, ...resetParams]);
}
};
return (
<div className="flex items-center gap-1">
<motion.div
initial={{ width: "auto" }}
animate={{ width: isFocused && text.length > 0 ? "24rem" : "auto" }}
transition={{
type: "spring",
stiffness: 300,
damping: 30,
}}
className="relative h-6 min-w-52"
>
<Input
type="text"
ref={inputRef}
variant="secondary-small"
placeholder={placeholder}
value={text}
onChange={(e) => updateText(e.target.value)}
fullWidth
autoFocus={autoFocus}
className={cn("", isFocused && "placeholder:text-text-dimmed/70")}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
handleSubmit();
}
if (e.key === "Escape") {
if (text.length > 0) {
e.stopPropagation();
handleClear();
} else {
e.currentTarget.blur();
}
}
}}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
icon={<MagnifyingGlassIcon className="size-4 text-text-bright" />}
accessory={
text.length > 0 ? (
<div className="-mr-1 flex items-center gap-1.5">
{!isControlled && (
<ShortcutKey
shortcut={{ key: "enter" }}
variant="medium"
className="border-none"
/>
)}
<SimpleTooltip
asChild
button={
<button
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={() => handleClear()}
className="flex size-4.5 items-center justify-center rounded-[2px] border border-text-dimmed/40 text-text-dimmed transition hover:bg-surface-control hover:text-text-bright"
>
<XMarkIcon className="size-3" />
</button>
}
content={
<div className="flex items-center gap-1">
<span className="text-text-dimmed">Clear field</span>
<ShortcutKey shortcut={{ key: "esc" }} variant="small" />
</div>
}
className="px-2 py-1.5 text-xs"
disableHoverableContent
/>
</div>
) : undefined
}
/>
</motion.div>
</div>
);
}
@@ -0,0 +1,153 @@
import { RadioGroup } from "@headlessui/react";
import { motion } from "framer-motion";
import type { ReactNode } from "react";
import { cn } from "~/utils/cn";
const sizes = {
small: {
control: "h-6",
option: "px-2 text-xs",
container: "gap-x-0.5",
},
medium: {
control: "h-10",
option: "px-3 py-[0.13rem] text-sm",
container: "p-1 gap-x-0.5",
},
};
const theme = {
primary: {
base: "bg-background-raised",
active: "text-text-bright hover:bg-background-hover/50",
inactive: "text-text-dimmed transition hover:text-text-bright",
selected: "absolute inset-0 rounded-[2px] outline-solid outline-3 outline-primary",
},
secondary: {
base: "bg-background-raised/50",
active: "text-text-bright",
inactive: "text-text-dimmed transition hover:text-text-bright",
selected: "absolute inset-0 rounded bg-background-raised border border-border-bright",
},
};
type Size = keyof typeof sizes;
type Theme = keyof typeof theme;
type VariantStyle = {
base: string;
active: string;
inactive: string;
option: string;
container: string;
selected: string;
};
function createVariant(sizeName: Size, themeName: Theme): VariantStyle {
return {
base: cn(sizes[sizeName].control, theme[themeName].base),
active: theme[themeName].active,
inactive: theme[themeName].inactive,
option: sizes[sizeName].option,
container: sizes[sizeName].container,
selected: theme[themeName].selected,
};
}
const variants = {
"primary/small": createVariant("small", "primary"),
"primary/medium": createVariant("medium", "primary"),
"secondary/small": createVariant("small", "secondary"),
"secondary/medium": createVariant("medium", "secondary"),
} as const;
type VariantType = keyof typeof variants;
type Options = {
label: ReactNode;
value: string;
};
type SegmentedControlProps = {
name: string;
value?: string;
defaultValue?: string;
options: Options[];
variant?: VariantType;
fullWidth?: boolean;
onChange?: (value: string) => void;
};
export default function SegmentedControl({
name,
value,
defaultValue,
options,
variant = "secondary/medium",
fullWidth,
onChange,
}: SegmentedControlProps) {
const variantStyle = variants[variant];
const _isPrimary = variant.startsWith("primary");
return (
<div
className={cn(
"flex rounded text-text-bright",
variantStyle.base,
fullWidth ? "w-full" : "w-fit"
)}
>
<RadioGroup
value={value}
defaultValue={defaultValue ?? options[0].value}
name={name}
onChange={(c: string) => {
if (onChange) {
onChange(c);
}
}}
className="w-full"
>
<div
className={cn("flex h-full w-full items-center justify-between", variantStyle.container)}
>
{options.map((option) => (
<RadioGroup.Option
key={option.value}
value={option.value}
className={({ checked }) =>
cn(
"relative flex h-full grow cursor-pointer text-center font-normal focus-custom",
checked ? variantStyle.active : variantStyle.inactive
)
}
>
{({ checked }) => (
<>
<div
className={cn(
"relative flex h-full w-full items-center justify-between",
variantStyle.option
)}
>
<div className="z-10 flex h-full w-full items-center justify-center">
<RadioGroup.Label as="p">{option.label}</RadioGroup.Label>
</div>
{checked && (
<motion.div
layoutId={`segmented-control-${name}`}
transition={{ duration: 0.4, type: "spring" }}
className={variantStyle.selected}
/>
)}
</div>
</>
)}
</RadioGroup.Option>
))}
</div>
</RadioGroup>
</div>
);
}
@@ -0,0 +1,707 @@
import * as Ariakit from "@ariakit/react";
import { type SelectProps as AriaSelectProps } from "@ariakit/react";
import { SelectValue } from "@ariakit/react-core/select/select-value";
import { Link } from "@remix-run/react";
import * as React from "react";
import { Fragment, useMemo, useState } from "react";
import { type ShortcutDefinition, useShortcutKeys } from "~/hooks/useShortcutKeys";
import { cn } from "~/utils/cn";
import { ShortcutKey } from "./ShortcutKey";
import { ChevronDown } from "lucide-react";
import { type MatchSorterOptions, matchSorter } from "match-sorter";
const sizes = {
small: {
button: "h-6 rounded text-xs px-2 ",
},
medium: {
button: "h-8 rounded px-3 text-sm",
},
};
const style = {
tertiary: {
button:
"bg-tertiary focus-custom border border-tertiary hover:text-text-bright hover:border-border-bright",
},
minimal: {
button:
"bg-transparent focus-custom hover:bg-tertiary disabled:bg-transparent disabled:pointer-events-none",
},
secondary: {
button:
"bg-secondary focus-custom border border-border-bright hover:text-text-bright hover:border-border-brighter text-text-bright hover:bg-surface-control",
},
};
const variants = {
"secondary/small": {
button: cn(sizes.small.button, style.secondary.button),
},
"tertiary/small": {
button: cn(sizes.small.button, style.tertiary.button),
},
"tertiary/medium": {
button: cn(sizes.medium.button, style.tertiary.button),
},
"minimal/small": {
button: cn(sizes.small.button, style.minimal.button),
},
"minimal/medium": {
button: cn(sizes.medium.button, style.minimal.button),
},
};
type Variant = keyof typeof variants;
type Section<TItem> = {
type: "section";
title?: string;
items: TItem[];
};
function isSection<TItem>(data: TItem[] | Section<TItem>[]): data is Section<TItem>[] {
const firstItem = data[0];
return (
(firstItem as Section<TItem>).type === "section" &&
(firstItem as Section<TItem>).items !== undefined &&
Array.isArray((firstItem as Section<TItem>).items)
);
}
type ItemFromSection<TItemOrSection> = TItemOrSection extends Section<infer U> ? U : TItemOrSection;
export interface SelectProps<TValue extends string | string[], TItem> extends Omit<
Ariakit.SelectProps,
"children"
> {
icon?: React.ReactNode;
text?: React.ReactNode | ((value: TValue) => React.ReactNode);
placeholder?: React.ReactNode;
value?: Ariakit.SelectProviderProps<TValue>["value"];
setValue?: Ariakit.SelectProviderProps<TValue>["setValue"];
defaultValue?: Ariakit.SelectProviderProps<TValue>["defaultValue"];
label?: string | Ariakit.SelectLabelProps["render"];
heading?: string;
showHeading?: boolean;
items?: TItem[] | Section<TItem>[];
empty?: React.ReactNode;
filter?:
| boolean
| MatchSorterOptions<TItem>
| ((item: ItemFromSection<TItem>, search: string, title?: string) => boolean);
children:
| React.ReactNode
| ((
items: ItemFromSection<TItem>[],
meta: {
shortcutsEnabled?: boolean;
section?: {
title?: string;
startIndex: number;
count: number;
};
}
) => React.ReactNode);
variant?: Variant;
open?: boolean;
setOpen?: (open: boolean) => void;
shortcut?: ShortcutDefinition;
tooltipTitle?: string;
allowItemShortcuts?: boolean;
clearSearchOnSelection?: boolean;
dropdownIcon?: boolean | React.ReactNode;
popoverClassName?: string;
placement?: Ariakit.SelectProviderProps<TValue>["placement"];
}
export function Select<TValue extends string | string[], TItem>({
children,
icon,
text,
placeholder,
value,
setValue,
defaultValue,
label,
heading,
showHeading = false,
items,
filter,
empty = null,
variant = "tertiary/small",
open,
setOpen,
shortcut,
tooltipTitle,
allowItemShortcuts = true,
disabled,
clearSearchOnSelection = true,
dropdownIcon,
popoverClassName,
placement,
...props
}: SelectProps<TValue, TItem>) {
const [searchValue, setSearchValue] = useState("");
const searchable = items !== undefined && filter !== undefined;
const matches = useMemo(() => {
if (!items) return [];
if (!searchValue || !filter) return items;
if (typeof filter === "function") {
if (isSection(items)) {
return items
.map((section) => ({
...section,
items: section.items.filter((item) =>
filter(item as ItemFromSection<TItem>, searchValue, section.title)
),
}))
.filter((section) => section.items.length > 0);
}
return items.filter((item) => filter(item as ItemFromSection<TItem>, searchValue));
}
if (typeof filter === "boolean" && filter) {
if (isSection(items)) {
return items
.map((section) => ({
...section,
items: matchSorter(section.items, searchValue),
}))
.filter((section) => section.items.length > 0);
}
return matchSorter(items, searchValue);
}
if (isSection(items)) {
return items
.map((section) => ({
...section,
items: matchSorter(section.items, searchValue, filter),
}))
.filter((section) => section.items.length > 0);
}
return matchSorter(items, searchValue, filter);
}, [searchValue, items]);
const enableItemShortcuts = allowItemShortcuts && matches.length === items?.length;
const select = (
<SelectProvider
open={open}
setOpen={setOpen}
virtualFocus={searchable}
placement={placement}
value={value}
setValue={(v) => {
if (clearSearchOnSelection) {
setSearchValue("");
}
if (setValue) {
setValue(v as any);
}
}}
defaultValue={defaultValue}
>
{label && <SelectLabel render={typeof label === "string" ? <div>{label}</div> : label} />}
<SelectTrigger
icon={icon}
variant={variant}
text={text}
placeholder={placeholder}
shortcut={shortcut}
tooltipTitle={tooltipTitle}
disabled={disabled}
dropdownIcon={dropdownIcon}
{...props}
/>
<SelectPopover className={popoverClassName}>
{!searchable && showHeading && heading && <SelectHeading render={<>{heading}</>} />}
{searchable && <ComboBox placeholder={heading} shortcut={shortcut} value={searchValue} />}
<SelectList>
{typeof children === "function" ? (
matches.length > 0 ? (
isSection(matches) ? (
<SelectGroupedRenderer items={matches} enableItemShortcuts={enableItemShortcuts}>
{children}
</SelectGroupedRenderer>
) : (
children(matches as ItemFromSection<TItem>[], {
shortcutsEnabled: enableItemShortcuts,
})
)
) : (
empty
)
) : (
children
)}
</SelectList>
</SelectPopover>
</SelectProvider>
);
if (searchable) {
return (
<ComboboxProvider
resetValueOnHide
setValue={(value) => {
React.startTransition(() => {
setSearchValue(value);
});
}}
>
{select}
</ComboboxProvider>
);
}
return select;
}
export interface SelectTriggerProps<TValue = any> extends AriaSelectProps {
icon?: React.ReactNode;
text?: React.ReactNode | ((value: TValue) => React.ReactNode);
placeholder?: React.ReactNode;
variant?: Variant;
shortcut?: ShortcutDefinition;
tooltipTitle?: string;
dropdownIcon?: boolean | React.ReactNode;
}
export function SelectTrigger({
icon,
variant = "tertiary/small",
text,
shortcut,
tooltipTitle,
disabled,
placeholder,
dropdownIcon = false,
children,
className,
...props
}: SelectTriggerProps) {
const ref = React.useRef<HTMLButtonElement>(null);
useShortcutKeys({
shortcut: shortcut,
action: (e) => {
e.preventDefault();
e.stopPropagation();
if (ref.current) {
ref.current.click();
}
},
disabled,
});
const showTooltip = tooltipTitle || shortcut;
const variantClasses = variants[variant];
let content: React.ReactNode = "";
if (children) {
content = children;
} else if (text !== undefined) {
if (typeof text === "function") {
content = <SelectValue>{(value) => <>{text(value) ?? placeholder}</>}</SelectValue>;
} else {
content = text;
}
} else {
content = (
<SelectValue>
{(value) => (
<>
{typeof value === "string"
? (value ?? placeholder)
: value.length === 0
? placeholder
: value.join(", ")}
</>
)}
</SelectValue>
);
}
return (
<Ariakit.TooltipProvider timeout={200}>
<Ariakit.TooltipAnchor
className="button"
render={
<Ariakit.Select
className={cn(
"group flex items-center gap-1 focus-custom disabled:cursor-not-allowed disabled:opacity-50",
variantClasses.button,
className
)}
ref={ref}
disabled={disabled}
{...props}
/>
}
>
<div className="flex min-w-0 grow items-center gap-0.5 overflow-hidden">
{icon && <div className="flex-none">{icon}</div>}
<div className="min-w-0 truncate">{content}</div>
</div>
{dropdownIcon === true ? (
<ChevronDown
className={cn(
"size-4 flex-none text-text-dimmed transition group-hover:text-text-bright group-focus:text-text-bright"
)}
/>
) : !dropdownIcon ? null : (
dropdownIcon
)}
</Ariakit.TooltipAnchor>
{showTooltip && (
<Ariakit.Tooltip
disabled={!tooltipTitle && !shortcut}
className="z-40 cursor-default rounded border border-grid-bright bg-background-bright px-2 py-1.5 text-xs"
>
<div className="flex items-center gap-2">
<span>{tooltipTitle ?? "Open menu"}</span>
{shortcut && (
<ShortcutKey
className={cn("size-4 flex-none")}
shortcut={shortcut}
variant={"small"}
/>
)}
</div>
</Ariakit.Tooltip>
)}
</Ariakit.TooltipProvider>
);
}
export interface SelectProviderProps<
TValue extends string | string[],
> extends Ariakit.SelectProviderProps<TValue> {}
export function SelectProvider<TValue extends string | string[]>(
props: SelectProviderProps<TValue>
) {
return <Ariakit.SelectProvider {...props} />;
}
export interface ComboboxProviderProps extends Ariakit.ComboboxProviderProps {}
export function ComboboxProvider(props: ComboboxProviderProps) {
return <Ariakit.ComboboxProvider {...props} />;
}
function SelectGroupedRenderer<TItem>({
items,
children,
enableItemShortcuts,
}: {
items: Section<TItem>[];
children: (
items: ItemFromSection<TItem>[],
meta: {
shortcutsEnabled?: boolean;
section?: { title?: string; startIndex: number; count: number };
}
) => React.ReactNode;
enableItemShortcuts: boolean;
}) {
let count = 0;
return (
<>
{items.map((section, index) => {
const previousItem = items.at(index - 1);
count += previousItem ? previousItem.items.length : 0;
return (
<Fragment key={index}>
{children(section.items as ItemFromSection<TItem>[], {
shortcutsEnabled: enableItemShortcuts,
section: {
title: section.title,
startIndex: count - 1,
count: section.items.length,
},
})}
</Fragment>
);
})}
</>
);
}
export interface SelectListProps extends Omit<Ariakit.SelectListProps, "store"> {}
export function SelectList(props: SelectListProps) {
const combobox = Ariakit.useComboboxContext();
const Component = combobox ? Ariakit.ComboboxList : Ariakit.SelectList;
return (
<Component
{...props}
className={cn(
"overflow-y-auto overflow-x-hidden overscroll-contain scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control focus-custom",
props.className
)}
/>
);
}
export interface SelectItemProps extends Ariakit.SelectItemProps {
icon?: React.ReactNode;
checkIcon?: React.ReactNode;
checkPosition?: "left" | "right";
shortcut?: ShortcutDefinition;
// Allow the item to grow to multiple lines and wrap its content instead of
// being locked to a single truncated line. Use for options with a subtitle.
wrap?: boolean;
}
const selectItemClasses =
"group cursor-pointer px-1 pt-1 text-2sm text-text-dimmed focus-custom last:pb-1";
import { CheckboxIndicator } from "./CheckboxIndicator";
export function SelectItem({
icon,
checkIcon = <Ariakit.SelectItemCheck className="size-8 flex-none text-text-bright" />,
checkPosition = "right",
shortcut,
wrap = false,
...props
}: SelectItemProps) {
const combobox = Ariakit.useComboboxContext();
// In a Combobox context we wrap the caller's render in ComboboxItem
// so combobox keyboard nav still works. Outside a Combobox we pass
// the render through verbatim — without this, callers like
// SelectLinkItem (which uses render to swap in a <Link>) get their
// render prop silently dropped, which is why those rows looked
// clickable but didn't navigate.
const render = combobox ? <Ariakit.ComboboxItem render={props.render} /> : props.render;
const ref = React.useRef<HTMLDivElement>(null);
const select = Ariakit.useSelectContext();
const selectValue = select?.useState("value");
const isChecked = React.useMemo(() => {
if (!props.value || selectValue == null) return false;
if (Array.isArray(selectValue)) return selectValue.includes(props.value);
return selectValue === props.value;
}, [selectValue, props.value]);
useShortcutKeys({
shortcut: shortcut,
action: (e) => {
e.preventDefault();
e.stopPropagation();
if (ref.current) {
ref.current.click();
}
},
disabled: props.disabled,
enabledOnInputElements: true,
});
return (
<Ariakit.SelectItem
{...props}
render={render}
blurOnHoverEnd={false}
className={cn(
selectItemClasses,
"[--padding-block:0.5rem] sm:[--padding-block:0.25rem]",
props.className
)}
ref={ref}
>
<div
className={cn(
"flex w-full items-center rounded-sm px-2 group-data-[active-item=true]:bg-tertiary hover:bg-tertiary",
wrap ? "min-h-8" : "h-8",
checkPosition === "left" ? "gap-2" : "gap-1"
)}
>
{checkPosition === "left" && <CheckboxIndicator checked={isChecked} />}
{icon}
<div className={cn("grow", wrap ? "min-w-0 break-words py-1.5" : "truncate")}>
{props.children || props.value}
</div>
{checkPosition === "right" && checkIcon}
{shortcut && (
<ShortcutKey
className={cn(
"size-4 flex-none transition duration-0 group-hover:border-border-bright"
)}
shortcut={shortcut}
variant={"small"}
/>
)}
</div>
</Ariakit.SelectItem>
);
}
export interface SelectLinkItemProps extends Ariakit.SelectItemProps {
icon?: React.ReactNode;
checkIcon?: React.ReactNode;
shortcut?: ShortcutDefinition;
to: string;
}
export function SelectLinkItem({
checkIcon = <Ariakit.SelectItemCheck className="size-8 flex-none text-white" />,
to,
...props
}: SelectLinkItemProps) {
const render = <Link to={to} className={cn("block", selectItemClasses, props.className)} />;
return (
<SelectItem
{...props}
render={render}
blurOnHoverEnd={false}
className={cn(selectItemClasses, props.className)}
/>
);
}
export interface SelectButtonItemProps extends Omit<Ariakit.SelectItemProps, "onClick"> {
icon?: React.ReactNode;
checkIcon?: React.ReactNode;
shortcut?: ShortcutDefinition;
onClick: React.ComponentProps<"button">["onClick"];
}
export function SelectButtonItem({
checkIcon = <Ariakit.SelectItemCheck className="size-8 flex-none text-white" />,
onClick,
...props
}: SelectButtonItemProps) {
const render = (
<button
onClick={onClick}
className={cn("block w-full text-left", selectItemClasses, props.className)}
/>
);
return (
<SelectItem
{...props}
render={render}
blurOnHoverEnd={false}
className={cn(selectItemClasses, props.className)}
/>
);
}
export function shortcutFromIndex(
index: number,
meta: {
shortcutsEnabled?: boolean;
section?: { startIndex: number };
}
): ShortcutDefinition | undefined {
if (!meta.shortcutsEnabled) return;
let adjustedIndex = index + (meta.section?.startIndex ?? 0);
if (adjustedIndex > 9) return;
if (adjustedIndex === 9) {
adjustedIndex = -1;
}
return { key: String(adjustedIndex + 1) };
}
export interface SelectSeparatorProps extends React.ComponentProps<"div"> {}
export function SelectSeparator(props: SelectSeparatorProps) {
return <div {...props} className={cn("h-px bg-background-raised", props.className)} />;
}
export interface SelectGroupProps extends Ariakit.SelectGroupProps {}
export function SelectGroup(props: SelectGroupProps) {
return <Ariakit.SelectGroup {...props} />;
}
export interface SelectGroupLabelProps extends Ariakit.SelectGroupLabelProps {}
export function SelectGroupLabel(props: SelectGroupLabelProps) {
return (
<Ariakit.SelectGroupLabel
{...props}
className={cn(
"flex h-5.5 items-center border-b border-grid-bright bg-background-hover px-2.5 text-xxs uppercase text-text-bright",
props.className
)}
/>
);
}
export interface SelectHeadingProps extends Ariakit.SelectHeadingProps {}
export function SelectHeading({ render, ...props }: SelectHeadingProps) {
return (
<div className="flex h-5.5 flex-none cursor-default items-center gap-2 border-b border-grid-bright bg-background-hover px-2.5 text-xxs uppercase text-text-bright">
<Ariakit.SelectHeading render={render} />
</div>
);
}
export interface SelectPopoverProps extends Ariakit.SelectPopoverProps {}
export function SelectPopover({
gutter = 5,
shift = 0,
unmountOnHide = true,
className,
...props
}: SelectPopoverProps) {
return (
<Ariakit.SelectPopover
gutter={gutter}
shift={shift}
unmountOnHide={unmountOnHide}
className={cn(
"z-50 flex flex-col overflow-clip rounded border border-grid-bright bg-background-bright shadow-md outline-hidden animate-in fade-in-40",
"min-w-[max(180px,var(--popover-anchor-width))]",
"max-w-[min(480px,var(--popover-available-width))]",
"max-h-[min(600px,var(--popover-available-height))]",
"origin-(--popover-transform-origin)",
className
)}
{...props}
/>
);
}
export interface SelectLabelProps extends Ariakit.SelectLabelProps {}
//currently unstyled
export function SelectLabel(props: SelectLabelProps) {
return <Ariakit.SelectLabel {...props} />;
}
export interface ComboBoxProps extends Ariakit.ComboboxProps {
shortcut?: ShortcutDefinition;
}
export function ComboBox({
autoSelect = true,
placeholder = "Filter options",
shortcut,
...props
}: ComboBoxProps) {
return (
<div className="flex h-9 w-full flex-none items-center border-b border-grid-dimmed bg-transparent px-3 text-xs text-text-dimmed outline-hidden">
<Ariakit.Combobox
autoSelect={autoSelect}
render={<input placeholder={placeholder} />}
className="flex-1 bg-transparent text-xs text-text-dimmed outline-hidden"
{...props}
/>
{shortcut && (
<ShortcutKey className={cn("size-4 flex-none")} shortcut={shortcut} variant={"small"} />
)}
</div>
);
}
@@ -0,0 +1,146 @@
"use client";
import { createContext, useCallback, useContext, useReducer } from "react";
type SelectedItemsContext = {
selectedItems: Set<string>;
select: (items: string | string[]) => void;
deselect: (items: string | string[]) => void;
toggle: (items: string | string[]) => void;
deselectAll: () => void;
has: (item: string) => boolean;
hasAll: (items: string[]) => boolean;
};
const SelectedItemsContext = createContext<SelectedItemsContext>({} as SelectedItemsContext);
export function useSelectedItems(enabled = true) {
const context = useContext(SelectedItemsContext);
if (!context && enabled) {
throw new Error("useSelectedItems must be used within a SelectedItemsProvider");
}
return context;
}
export function SelectedItemsProvider({
initialSelectedItems,
maxSelectedItemCount,
children,
}: {
initialSelectedItems: string[];
maxSelectedItemCount?: number;
children: React.ReactNode | ((context: SelectedItemsContext) => React.ReactNode);
}) {
const [state, dispatch] = useReducer(selectedItemsReducer, {
items: new Set<string>(initialSelectedItems),
maxSelectedItemCount,
});
const select = useCallback((items: string | string[]) => {
dispatch({ type: "select", items: Array.isArray(items) ? items : [items] });
}, []);
const deselect = useCallback((items: string | string[]) => {
dispatch({ type: "deselect", items: Array.isArray(items) ? items : [items] });
}, []);
const toggle = useCallback((items: string | string[]) => {
dispatch({ type: "toggle", items: Array.isArray(items) ? items : [items] });
}, []);
const deselectAll = useCallback(() => {
dispatch({ type: "deselectAll" });
}, []);
const has = useCallback((item: string) => state.items.has(item), [state]);
const hasAll = useCallback(
(items: string[]) => items.every((item) => state.items.has(item)),
[state]
);
return (
<SelectedItemsContext.Provider
value={{ selectedItems: state.items, select, deselect, toggle, deselectAll, has, hasAll }}
>
{typeof children === "function"
? children({
selectedItems: state.items,
select,
deselect,
toggle,
deselectAll,
has,
hasAll,
})
: children}
</SelectedItemsContext.Provider>
);
}
type SelectItemsAction = {
type: "select";
items: string[];
};
type DeSelectItemsAction = {
type: "deselect";
items: string[];
};
type DeselectAllItemsAction = {
type: "deselectAll";
};
type ToggleItemsAction = {
type: "toggle";
items: string[];
};
type Action = SelectItemsAction | DeSelectItemsAction | ToggleItemsAction | DeselectAllItemsAction;
function selectedItemsReducer(
state: { items: Set<string>; maxSelectedItemCount?: number },
action: Action
) {
switch (action.type) {
case "select":
const items = new Set([...state.items, ...action.items]);
return { ...state, items: cappedSet(items, state.maxSelectedItemCount) };
case "deselect":
const newItems = new Set(state.items);
action.items.forEach((item) => {
newItems.delete(item);
});
return { ...state, items: cappedSet(newItems, state.maxSelectedItemCount) };
case "toggle":
let newSet = new Set(state.items);
action.items.forEach((item) => {
if (newSet.has(item)) {
newSet.delete(item);
} else {
newSet.add(item);
}
});
return { ...state, items: cappedSet(newSet, state.maxSelectedItemCount) };
case "deselectAll":
return { ...state, items: new Set<string>() };
default:
return state;
}
}
function cappedSet(set: Set<string>, max?: number) {
if (!max) {
return set;
}
if (set.size <= max) {
return set;
}
console.warn(`Selected items exceeded the maximum count of ${max}.`);
return new Set([...set].slice(0, max));
}
@@ -0,0 +1,216 @@
import { type ReactNode } from "react";
import { MainHorizontallyCenteredContainer } from "~/components/layout/AppLayout";
import { cn } from "~/utils/cn";
import { Header2, Header3 } from "./Headers";
import { Paragraph } from "./Paragraph";
// A composable layout system for settings pages: a centered container holds
// sections; each section has a header (title/description/action over a divide)
// followed by rows. A row lays out a title + description on the left and an
// action (button/switch/select/status) on the right, separated by divides and
// spacing rather than bordered boxes.
//
// Everything that renders text accepts `ReactNode`, and every piece takes a
// `className` so callers can restyle without forking. For layouts the built-in
// props don't cover, pass `children` to a row/block for full control.
const rowSize = {
sm: "py-3",
md: "py-4",
} as const;
type RowSize = keyof typeof rowSize;
/** Page-level wrapper that centers content and sets the settings column width. */
export function SettingsContainer({
children,
className,
}: {
children: ReactNode;
className?: string;
}) {
return (
<MainHorizontallyCenteredContainer
className={cn("max-w-[37.5rem] overflow-visible", className)}
>
{children}
</MainHorizontallyCenteredContainer>
);
}
/** A group of related rows. Adds vertical spacing between sibling sections. */
export function SettingsSection({
children,
className,
}: {
children: ReactNode;
className?: string;
}) {
return (
<section className={cn("w-full [&:not(:first-child)]:mt-12", className)}>{children}</section>
);
}
/**
* Section (or sub-section) heading with an optional description and a
* right-aligned action, sitting above a bottom divide. Use `as="h3"` for a
* heading nested inside a section.
*/
export function SettingsHeader({
title,
description,
action,
as = "h2",
className,
}: {
title: ReactNode;
description?: ReactNode;
action?: ReactNode;
as?: "h2" | "h3";
className?: string;
}) {
const Heading = as === "h3" ? Header3 : Header2;
return (
<div
className={cn(
"flex w-full items-end justify-between gap-8 border-b border-grid-dimmed",
// An h2 section header gets its top spacing from SettingsSection's margin,
// so it only needs bottom padding. An h3 sits mid-section among rows, so it
// takes the full row rhythm (py-4) to separate from the divide above it.
as === "h3" ? "py-4" : "pb-3",
className
)}
>
<div className="space-y-1">
<Heading>{title}</Heading>
{description ? <Paragraph variant="small">{description}</Paragraph> : null}
</div>
{action ? <div className="flex flex-none items-center">{action}</div> : null}
</div>
);
}
/** Title typography for a row. Renders a `<label>` when `htmlFor` is set. */
export function SettingsRowTitle({
children,
htmlFor,
className,
}: {
children: ReactNode;
htmlFor?: string;
className?: string;
}) {
const classes = cn("font-sans text-sm font-semibold leading-tight text-text-bright", className);
return htmlFor ? (
<label htmlFor={htmlFor} className={classes}>
{children}
</label>
) : (
<span className={classes}>{children}</span>
);
}
/** Description/subtitle typography for a row. */
export function SettingsRowDescription({
children,
className,
}: {
children: ReactNode;
className?: string;
}) {
return (
<Paragraph variant="small" className={className}>
{children}
</Paragraph>
);
}
/**
* A single settings row: title + description on the left, action on the right.
*
* Pass `title`/`description` for the common case, or `children` to supply
* custom left-hand content (the built-in title group is skipped when `children`
* is provided). `action` renders on the right in both cases.
*/
export function SettingsRow({
title,
description,
action,
htmlFor,
children,
className,
titleClassName,
size = "md",
align = "center",
bordered = true,
}: {
title?: ReactNode;
description?: ReactNode;
action?: ReactNode;
htmlFor?: string;
children?: ReactNode;
className?: string;
titleClassName?: string;
size?: RowSize;
align?: "center" | "start";
bordered?: boolean;
}) {
return (
<div
className={cn(
"flex w-full justify-between gap-8",
align === "center" ? "items-center" : "items-start",
rowSize[size],
bordered && "border-b border-grid-dimmed",
className
)}
>
{children ?? (
<div className="flex-1 space-y-1">
{title ? (
<SettingsRowTitle htmlFor={htmlFor} className={titleClassName}>
{title}
</SettingsRowTitle>
) : null}
{description ? <SettingsRowDescription>{description}</SettingsRowDescription> : null}
</div>
)}
{action ? <div className="flex flex-none items-center">{action}</div> : null}
</div>
);
}
/**
* Full-width row for arbitrary content (callouts, empty states, custom blocks)
* that shouldn't be split into a title/action layout.
*/
export function SettingsBlock({
children,
className,
size = "md",
bordered = true,
}: {
children: ReactNode;
className?: string;
size?: RowSize;
bordered?: boolean;
}) {
return (
<div
className={cn("w-full", rowSize[size], bordered && "border-b border-grid-dimmed", className)}
>
{children}
</div>
);
}
/** Right-aligned action bar, typically for a section's Save button. */
export function SettingsActions({
children,
className,
}: {
children: ReactNode;
className?: string;
}) {
return <div className={cn("flex justify-end gap-2 py-4", className)}>{children}</div>;
}
@@ -0,0 +1,201 @@
"use client";
import * as SheetPrimitive from "@radix-ui/react-dialog";
import type { VariantProps } from "class-variance-authority";
import { cva } from "class-variance-authority";
import * as React from "react";
import { cn } from "~/utils/cn";
import { ShortcutKey } from "./ShortcutKey";
import { XMarkIcon } from "@heroicons/react/20/solid";
const Sheet = SheetPrimitive.Root;
const SheetTrigger = SheetPrimitive.Trigger;
const portalVariants = cva("fixed inset-0 z-50 flex", {
variants: {
position: {
top: "items-start",
bottom: "items-end",
left: "justify-start",
right: "justify-end",
},
},
defaultVariants: { position: "right" },
});
interface SheetPortalProps
extends SheetPrimitive.DialogPortalProps, VariantProps<typeof portalVariants> {}
const SheetPortal = ({ position, children, ...props }: SheetPortalProps) => (
<SheetPrimitive.Portal {...props}>
<div className={portalVariants({ position })}>{children}</div>
</SheetPrimitive.Portal>
);
SheetPortal.displayName = SheetPrimitive.Portal.displayName;
const SheetOverlay = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
>(({ className, children, ...props }, ref) => (
<SheetPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-background-dimmed/80 transition duration-200 animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out data-[state=open]:fade-in",
className
)}
{...props}
ref={ref}
/>
));
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
const sheetVariants = cva(
"fixed z-50 scale-100 gap-4 shadow-lg bg-background-bright opacity-100 border-l border-grid-bright",
{
variants: {
position: {
top: "animate-in slide-in-from-top w-full duration-200",
bottom: "animate-in slide-in-from-bottom w-full duration-200",
left: "animate-in slide-in-from-left h-full duration-200",
right: "animate-in slide-in-from-right h-screen duration-200",
},
size: {
content: "",
default: "",
sm: "",
lg: "",
xl: "",
full: "",
},
},
compoundVariants: [
{
position: ["top", "bottom"],
size: "content",
class: "max-h-screen",
},
{
position: ["top", "bottom"],
size: "default",
class: "h-1/3",
},
{
position: ["top", "bottom"],
size: "sm",
class: "h-1/4",
},
{
position: ["top", "bottom"],
size: "lg",
class: "h-1/2",
},
{
position: ["top", "bottom"],
size: "xl",
class: "h-5/6",
},
{
position: ["top", "bottom"],
size: "full",
class: "h-screen",
},
{
position: ["right", "left"],
size: "content",
class: "max-w-screen",
},
{
position: ["right", "left"],
size: "default",
class: "w-1/3",
},
{
position: ["right", "left"],
size: "sm",
class: "w-1/4",
},
{
position: ["right", "left"],
size: "lg",
class: "w-1/2",
},
{
position: ["right", "left"],
size: "xl",
class: "w-5/6",
},
{
position: ["right", "left"],
size: "full",
class: "w-screen",
},
],
defaultVariants: {
position: "right",
size: "default",
},
}
);
export interface DialogContentProps
extends
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
VariantProps<typeof sheetVariants> {}
const SheetContent = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Content>,
DialogContentProps
>(({ position, size, className, children, ...props }, ref) => (
<SheetPortal position={position}>
<SheetOverlay />
<SheetPrimitive.Content
ref={ref}
className={cn(sheetVariants({ position, size }), className)}
{...props}
>
<div className="grid max-h-full grid-rows-[2.75rem_1fr] overflow-hidden">
<div className="flex items-center gap-2 border-b border-grid-bright p-2">
<SheetPrimitive.Close className="rounded-sm p-1 transition hover:bg-background-bright disabled:pointer-events-none">
<XMarkIcon className="size-4 text-text-dimmed" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
<ShortcutKey shortcut={{ key: "esc" }} variant="small" />
</div>
<div className="flex max-h-full flex-col overflow-hidden">{children}</div>
</div>
</SheetPrimitive.Content>
</SheetPortal>
));
SheetContent.displayName = SheetPrimitive.Content.displayName;
export const SheetBody = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"overflow-y-auto p-4 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control",
className
)}
{...props}
/>
);
export const SheetHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"mx-4 flex shrink-0 items-center gap-4 border-b border-grid-bright py-3.5",
className
)}
{...props}
/>
);
export const SheetFooter = ({
className,
children,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("shrink-0", className)} {...props}>
<div className="mx-4 border-t border-grid-bright py-3">{children}</div>
</div>
);
export { Sheet, SheetContent, SheetTrigger };
@@ -0,0 +1,126 @@
import { XMarkIcon } from "@heroicons/react/20/solid";
import * as SheetPrimitive from "@radix-ui/react-dialog";
import { cva, type VariantProps } from "class-variance-authority";
import * as React from "react";
import { cn } from "~/utils/cn";
import { ShortcutKey } from "./ShortcutKey";
const Sheet = SheetPrimitive.Root;
const SheetTrigger = SheetPrimitive.Trigger;
const SheetClose = SheetPrimitive.Close;
const SheetPortal = SheetPrimitive.Portal;
const SheetOverlay = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
ref={ref}
/>
));
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
const sheetVariants = cva(
"fixed z-50 gap-4 overflow-y-auto bg-background-dimmed shadow-lg transition ease-in-out border-grid-dimmed data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-300",
{
variants: {
side: {
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
bottom:
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
right:
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
},
},
defaultVariants: {
side: "right",
},
}
);
interface SheetContentProps
extends
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
VariantProps<typeof sheetVariants> {}
const SheetContent = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Content>,
SheetContentProps
>(({ side = "right", className, children, ...props }, ref) => (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content ref={ref} className={cn(sheetVariants({ side }), className)} {...props}>
{children}
</SheetPrimitive.Content>
</SheetPortal>
));
SheetContent.displayName = SheetPrimitive.Content.displayName;
const SheetHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col space-y-2 text-center sm:text-left", className)} {...props} />
);
SheetHeader.displayName = "SheetHeader";
const SheetFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)}
{...props}
/>
);
SheetFooter.displayName = "SheetFooter";
const SheetTitle = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
>(({ className, children, ...props }, ref) => (
<SheetPrimitive.Title
ref={ref}
className={cn(
"sticky top-0 flex items-center justify-between border-b border-grid-bright bg-background-dimmed pb-1.5 pl-3 pr-1.5 pt-2",
className
)}
{...props}
>
{children}
<SheetPrimitive.Close className="flex items-center gap-1 rounded-sm p-1 pl-0 transition hover:bg-background-hover focus-visible:focus-custom disabled:pointer-events-none">
<ShortcutKey shortcut={{ key: "esc" }} variant="small" />
<XMarkIcon className="size-4 text-text-dimmed" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Title>
));
SheetTitle.displayName = SheetPrimitive.Title.displayName;
const SheetDescription = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Description
ref={ref}
className={cn("overflow-y-auto px-3 py-1", className)}
{...props}
/>
));
SheetDescription.displayName = SheetPrimitive.Description.displayName;
export {
Sheet,
SheetClose,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetOverlay,
SheetPortal,
SheetTitle,
SheetTrigger,
};
@@ -0,0 +1,111 @@
import { KeyboardDownIcon } from "~/assets/icons/KeyboardDownIcon";
import { KeyboardLeftIcon } from "~/assets/icons/KeyboardLeftIcon";
import { KeyboardRightIcon } from "~/assets/icons/KeyboardRightIcon";
import { KeyboardUpIcon } from "~/assets/icons/KeyboardUpIcon";
import { KeyboardWindowsIcon } from "~/assets/icons/KeyboardWindowsIcon";
import { type Modifier, type Shortcut } from "~/hooks/useShortcutKeys";
import { cn } from "~/utils/cn";
import { useOperatingSystem } from "./OperatingSystemProvider";
import { KeyboardEnterIcon } from "~/assets/icons/KeyboardEnterIcon";
const small =
"justify-center text-[0.6rem] font-mono font-medium min-w-4 min-h-4 rounded-[2px] tabular-nums px-1 ml-1 -mr-0.5 flex items-center gap-x-1 border transition uppercase";
const medium =
"justify-center min-w-5 min-h-5 text-[0.65rem] font-mono font-medium rounded-[2px] tabular-nums px-1 ml-1 -mr-0.5 flex items-center gap-x-1.5 border border-dimmed/40 text-text-dimmed group-hover:text-text-bright/80 group-hover:border-dimmed/60 transition uppercase";
export const variants = {
small: cn(
small,
"border-text-dimmed/40 text-text-dimmed group-hover:text-text-bright/80 group-hover:border-text-dimmed/60"
),
"small/bright": cn(small, "bg-background-hover text-text-bright border-border-bright"),
medium: cn(medium, "group-hover:border-border-brighter"),
"medium/bright": cn(medium, "bg-background-hover text-text-bright border-border-bright"),
};
export type ShortcutKeyVariant = keyof typeof variants;
type ShortcutKey = Partial<Shortcut>;
type ShortcutKeyDefinition =
| {
windows: ShortcutKey;
mac: ShortcutKey;
}
| ShortcutKey;
type ShortcutKeyProps = {
shortcut: ShortcutKeyDefinition;
variant: ShortcutKeyVariant;
className?: string;
};
export function ShortcutKey({ shortcut, variant, className }: ShortcutKeyProps) {
const { platform } = useOperatingSystem();
const isMac = platform === "mac";
let relevantShortcut = "mac" in shortcut ? (isMac ? shortcut.mac : shortcut.windows) : shortcut;
const modifiers = relevantShortcut.modifiers ?? [];
const character = relevantShortcut.key ? keyString(relevantShortcut.key, isMac, variant) : null;
return (
<span className={cn(variants[variant], className)}>
{modifiers.map((k) => (
<span key={k}>
<span>{modifierString(k, isMac, variant)}</span>
</span>
))}
{character && <span>{character}</span>}
</span>
);
}
function keyString(key: string, isMac: boolean, variant: ShortcutKeyVariant) {
key = key.toLowerCase();
const className = variant.startsWith("small") ? "w-2.5 h-4" : "w-2.5 h-4.5";
switch (key) {
case "enter":
return isMac ? (
<KeyboardEnterIcon className={className} />
) : (
<span className="capitalize">Enter</span>
);
case "esc":
return <span className="capitalize">Esc</span>;
case "del":
return <span className="capitalize">Del</span>;
case "arrowdown":
return <KeyboardDownIcon className={className} />;
case "arrowup":
return <KeyboardUpIcon className={className} />;
case "arrowleft":
return <KeyboardLeftIcon className={className} />;
case "arrowright":
return <KeyboardRightIcon className={className} />;
default:
return key;
}
}
function modifierString(
modifier: Modifier,
isMac: boolean,
variant: ShortcutKeyVariant
): string | JSX.Element {
const className = variant.startsWith("small") ? "w-2.5 h-4" : "w-3.5 h-5";
switch (modifier) {
case "alt":
return isMac ? "⌥" : <span className="capitalize">Alt</span>;
case "ctrl":
return isMac ? "⌃" : <span className="capitalize">Ctrl</span>;
case "meta":
return isMac ? "⌘" : <KeyboardWindowsIcon className={className} />;
case "shift":
return "⇧";
case "mod":
return isMac ? "⌘" : <span className="capitalize">Ctrl</span>;
}
}
@@ -0,0 +1,39 @@
import { createContext, useContext, useState, useCallback, useMemo, type ReactNode } from "react";
type ShortcutsContextType = {
areShortcutsEnabled: boolean;
disableShortcuts: () => void;
enableShortcuts: () => void;
};
const ShortcutsContext = createContext<ShortcutsContextType | null>(null);
type ShortcutsProviderProps = {
children: ReactNode;
};
export function ShortcutsProvider({ children }: ShortcutsProviderProps) {
const [areShortcutsEnabled, setAreShortcutsEnabled] = useState(true);
const disableShortcuts = useCallback(() => setAreShortcutsEnabled(false), []);
const enableShortcuts = useCallback(() => setAreShortcutsEnabled(true), []);
const value = useMemo(
() => ({
areShortcutsEnabled,
disableShortcuts,
enableShortcuts,
}),
[areShortcutsEnabled, disableShortcuts, enableShortcuts]
);
return <ShortcutsContext.Provider value={value}>{children}</ShortcutsContext.Provider>;
}
const throwIfNoProvider = () => {
throw new Error("useShortcuts must be used within a ShortcutsProvider");
};
export const useShortcuts = () => {
return useContext(ShortcutsContext) ?? throwIfNoProvider();
};
@@ -0,0 +1,143 @@
"use client";
import * as SelectPrimitive from "@radix-ui/react-select";
import { Check, ChevronDown } from "lucide-react";
import * as React from "react";
import { cn } from "~/utils/cn";
const sizes = {
"secondary/small":
"text-xs h-6 bg-tertiary border border-tertiary group-hover:text-text-bright hover:border-border-bright pr-2 pl-1.5",
medium: "text-sm h-8 bg-tertiary border border-tertiary hover:border-border-bright px-2.5",
minimal: "text-xs h-6 bg-transparent hover:bg-tertiary pl-1.5 pr-2",
};
export type SelectProps = {
size?: keyof typeof sizes;
width?: "content" | "full";
};
const Select = SelectPrimitive.Root;
const SelectGroup = SelectPrimitive.Group;
const SelectValue = SelectPrimitive.Value;
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger> & SelectProps
>(({ className, children, width = "content", size = "secondary/small", ...props }, ref) => {
const sizeClassName = sizes[size];
return (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"ring-offset-background group flex items-center justify-between gap-x-1 rounded text-text-dimmed transition placeholder:text-text-dimmed hover:text-text-bright focus-visible:focus-custom disabled:cursor-not-allowed disabled:opacity-50",
width === "full" ? "w-full" : "w-min",
sizeClassName,
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown
className={cn(
"size-4 text-text-dimmed transition group-hover:text-text-bright group-focus:text-text-bright"
)}
/>
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
);
});
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 min-w-max overflow-hidden rounded-md border border-grid-bright bg-background-dimmed text-text-bright shadow-md animate-in fade-in-40",
position === "popper" && "translate-y-1",
className
)}
position={position}
{...props}
>
<SelectPrimitive.Viewport
className={cn(
"space-y-0.5 px-1 py-1",
position === "popper" &&
"h-(--radix-select-trigger-height) w-full min-w-(--radix-select-trigger-width)"
)}
>
{children}
</SelectPrimitive.Viewport>
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
));
SelectContent.displayName = SelectPrimitive.Content.displayName;
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn(
"-ml-1 -mr-1 mb-1 bg-background-deep py-1.5 pl-2 pr-2 font-sans text-xxs font-normal uppercase leading-normal tracking-wider text-text-dimmed first-of-type:mt-0",
className
)}
{...props}
/>
));
SelectLabel.displayName = SelectPrimitive.Label.displayName;
type SelectItemProps = React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item> & {
contentClassName?: string;
};
const SelectItem = React.forwardRef<React.ElementRef<typeof SelectPrimitive.Item>, SelectItemProps>(
({ className, children, contentClassName, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-12 text-sm outline-hidden transition data-disabled:pointer-events-none data-disabled:opacity-50 hover:bg-background-hover focus:bg-background-hover/50",
className
)}
{...props}
>
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
);
SelectItem.displayName = SelectPrimitive.Item.displayName;
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("bg-muted -mx-1 my-1 h-px", className)}
{...props}
/>
));
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectSeparator,
SelectTrigger,
SelectValue,
};
@@ -0,0 +1,53 @@
import * as RadixSlider from "@radix-ui/react-slider";
import type { ComponentProps } from "react";
import { cn } from "~/utils/cn";
import type { RenderIcon } from "./Icon";
import { Icon } from "./Icon";
const variants = {
tertiary: {
container: "h-6 gap-1 rounded-sm hover:bg-background-raised px-1",
icons: "h-4 w-4 text-text-bright",
root: "h-4",
track: "h-1 bg-grid-bright group-hover:bg-background-dimmed",
range: "bg-transparent group-hover:bg-secondary",
thumb:
"h-3 w-3 border-2 border-text-dimmed bg-grid-bright shadow-[0_1px_3px_4px_rgb(0_0_0/0.2),0_1px_2px_-1px_rgb(0_0_0/0.1)] hover:border-text-dimmed focus:shadow-[0_1px_3px_4px_rgb(0_0_0/0.2),0_1px_2px_-1px_rgb(0_0_0/0.1)]",
},
};
type VariantName = keyof typeof variants;
export type SliderProps = ComponentProps<typeof RadixSlider.Root> & {
LeadingIcon?: RenderIcon;
TrailingIcon?: RenderIcon;
variant: VariantName;
};
export function Slider({ variant, className, LeadingIcon, TrailingIcon, ...props }: SliderProps) {
const variation = variants[variant];
return (
<div className={cn("group flex items-center", variation.container)}>
{LeadingIcon && <Icon icon={LeadingIcon} className={variation.icons} />}
<RadixSlider.Root
className={cn(
"relative flex touch-none select-none items-center",
variation.root,
className
)}
{...props}
>
<RadixSlider.Track className={cn("relative grow rounded-full", variation.track)}>
<RadixSlider.Range className={cn("absolute h-full rounded-full", variation.range)} />
</RadixSlider.Track>
<RadixSlider.Thumb
className={cn(
"block cursor-pointer rounded-full transition focus:outline-hidden",
variation.thumb
)}
/>
</RadixSlider.Root>
{TrailingIcon && <Icon icon={TrailingIcon} className={variation.icons} />}
</div>
);
}
@@ -0,0 +1,78 @@
import { cn } from "~/utils/cn";
type CustomColor = {
background: string;
foreground: string;
};
export function Spinner({
className,
color = "blue",
}: {
className?: string;
color?: "blue" | "white" | "muted" | "dark" | CustomColor;
}) {
const colors = {
blue: {
background: "rgba(59, 130, 246, 0.4)",
foreground: "rgba(59, 130, 246)",
},
white: {
background: "rgba(255, 255, 255, 0.4)",
foreground: "rgba(255, 255, 255)",
},
muted: {
background: "#1C2433",
foreground: "#3C4B62",
},
dark: {
background: "rgba(18, 19, 23, 0.35)",
foreground: "#1A1B1F",
},
};
const currentColor = typeof color === "string" ? colors[color] : color;
return (
<svg
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={cn("animate-spin motion-reduce:hidden", className)}
>
<rect
x="2"
y="2"
width="16"
height="16"
rx="8"
stroke={currentColor.background}
strokeWidth="3"
/>
<path
d="M10 18C5.58172 18 2 14.4183 2 10C2 5.58172 5.58172 2 10 2"
stroke={currentColor.foreground}
strokeWidth="3"
strokeLinecap="round"
/>
</svg>
);
}
export function ButtonSpinner() {
return (
<Spinner
className="size-3"
color={{
background: "rgba(255, 255, 255, 0.4)",
foreground: "rgba(255, 255, 255)",
}}
/>
);
}
export function SpinnerWhite({ className }: { className?: string }) {
return <Spinner className={className} color="white" />;
}
@@ -0,0 +1,48 @@
import { cn } from "~/utils/cn";
import { Header2 } from "./Headers";
import { Spinner } from "./Spinner";
import { CheckIcon } from "@heroicons/react/24/solid";
export function StepNumber({
stepNumber,
active = false,
complete = false,
displaySpinner = false,
title,
className,
}: {
stepNumber?: string;
active?: boolean;
complete?: boolean;
title?: React.ReactNode;
className?: string;
displaySpinner?: boolean;
}) {
return (
<div className={cn("mr-3", className)}>
{active ? (
<div className="flex items-center gap-x-3">
<span className="flex h-6 w-6 items-center justify-center rounded bg-success py-1 text-xs font-semibold text-charcoal-900">
{stepNumber}
</span>
<Header2>{title}</Header2>
</div>
) : (
<div className="flex items-center gap-x-3">
<span className="flex h-6 w-6 items-center justify-center rounded border border-grid-bright bg-background-bright py-1 text-xs font-semibold text-text-dimmed">
{complete ? <CheckIcon className="size-4" /> : stepNumber}
</span>
{displaySpinner ? (
<div className="flex items-center gap-x-2">
<Header2>{title}</Header2>
<Spinner />
</div>
) : (
<Header2>{title}</Header2>
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,119 @@
"use client";
import * as React from "react";
import * as SwitchPrimitives from "@radix-ui/react-switch";
import { cn } from "~/utils/cn";
import { type ShortcutDefinition, useShortcutKeys } from "~/hooks/useShortcutKeys";
const small = {
container:
"flex items-center h-6 gap-x-1.5 rounded hover:bg-tertiary pr-1 py-[0.1rem] pl-1.5 hover:disabled:bg-background-raised transition focus-custom disabled:opacity-50 text-text-dimmed hover:text-text-bright disabled:hover:cursor-not-allowed hover:cursor-pointer disabled:hover:text-rose-500",
root: "h-3 w-6",
thumb: "size-2.5 data-[state=checked]:translate-x-2.5 data-[state=unchecked]:translate-x-0",
text: "text-xs text-text-dimmed",
};
const variations = {
large: {
container: "flex items-center gap-x-2 rounded-md hover:bg-tertiary p-2 transition focus-custom",
root: "h-6 w-11",
thumb: "size-5 data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0",
text: "text-sm text-text-dimmed",
},
small,
"tertiary/small": {
container: small.container,
root: cn(
small.root,
"group-data-[state=unchecked]:bg-surface-control group-hover:group-data-[state=unchecked]:bg-surface-control-active/50"
),
thumb: small.thumb,
text: cn(
small.text,
"transition group-hover:text-text-bright group-hover:group-disabled:text-text-dimmed"
),
},
"secondary/small": {
container: cn(
small.container,
"border border-border-bright hover:border-border-brighter bg-secondary hover:bg-surface-control"
),
root: cn(
small.root,
"group-data-[state=unchecked]:bg-surface-control-hover group-hover:group-data-[state=unchecked]:bg-surface-control-active"
),
thumb: small.thumb,
text: cn(small.text, "transition text-text-bright group-hover:group-disabled:text-text-dimmed"),
},
medium: {
container:
"flex items-center gap-x-2 rounded-md hover:bg-tertiary py-1.5 px-2 transition focus-custom",
root: "h-4 w-8",
thumb: "size-3.5 data-[state=checked]:translate-x-3.5 data-[state=unchecked]:translate-x-0",
text: "text-sm text-text-dimmed",
},
};
type SwitchProps = React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root> & {
label?: React.ReactNode;
variant: keyof typeof variations;
shortcut?: ShortcutDefinition;
labelPosition?: "left" | "right";
};
export const Switch = React.forwardRef<React.ElementRef<typeof SwitchPrimitives.Root>, SwitchProps>(
({ className, variant, label, labelPosition = "left", ...props }, ref) => {
const innerRef = React.useRef<HTMLButtonElement>(null);
React.useImperativeHandle(ref, () => innerRef.current as HTMLButtonElement);
const { container, root, thumb, text } = variations[variant];
if (props.shortcut) {
useShortcutKeys({
shortcut: props.shortcut,
action: () => {
if (innerRef.current) {
innerRef.current.click();
}
},
disabled: props.disabled,
});
}
const labelElement = label ? (
<label
className={cn("cursor-pointer whitespace-nowrap group-disabled:cursor-not-allowed", text)}
>
{typeof label === "string" ? <span>{label}</span> : label}
</label>
) : null;
const switchElement = (
<div
className={cn(
"inline-flex shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors group-disabled:cursor-not-allowed group-disabled:opacity-50 group-data-[state=checked]:bg-blue-500 group-data-[state=unchecked]:bg-background-raised group-hover:group-data-[state=unchecked]:bg-surface-control-active/50",
root
)}
>
<SwitchPrimitives.Thumb
className={cn(
thumb,
"pointer-events-none block rounded-full bg-charcoal-200 transition group-data-[state=checked]:bg-text-bright"
)}
/>
</div>
);
return (
<SwitchPrimitives.Root
className={cn("group", container, className)}
{...props}
ref={innerRef}
>
{labelPosition === "left" ? labelElement : null}
{switchElement}
{labelPosition === "right" ? labelElement : null}
</SwitchPrimitives.Root>
);
}
);
@@ -0,0 +1,538 @@
import { ChevronRightIcon } from "@heroicons/react/24/solid";
import { Link } from "@remix-run/react";
import { ClipboardCheckIcon, ClipboardIcon } from "lucide-react";
import React, { type ReactNode, createContext, forwardRef, useContext, useState } from "react";
import { useCopy } from "~/hooks/useCopy";
import { cn } from "~/utils/cn";
import { Popover, PopoverContent, PopoverVerticalEllipseTrigger } from "./Popover";
import { InfoIconTooltip, SimpleTooltip } from "./Tooltip";
const variants = {
bright: {
header: "bg-background-bright",
headerCell: "px-3 py-2.5 pb-3 text-sm",
cell: "group-hover/table-row:bg-background-hover group-has-[[tabindex='0']:focus]/table-row:bg-background-hover",
cellSize: "px-3 py-3",
cellText: "text-xs group-hover/table-row:text-text-bright",
stickyCell: "bg-background-bright group-hover/table-row:bg-background-hover",
menuButton:
"bg-background-bright group-hover/table-row:bg-background-hover group-hover/table-row:ring-border-bright/70 group-has-[[tabindex='0']:focus]/table-row:bg-background-hover",
menuButtonDivider: "group-hover/table-row:border-border-bright/70",
rowSelected: "bg-background-hover group-hover/table-row:bg-background-hover",
},
"bright/no-hover": {
header: "bg-transparent",
headerCell: "px-3 py-2.5 pb-3 text-sm",
cell: "group-hover/table-row:bg-transparent",
cellSize: "px-3 py-3",
cellText: "text-xs",
stickyCell: "bg-background-bright",
menuButton: "bg-background-bright",
menuButtonDivider: "",
rowSelected: "bg-background-hover",
},
dimmed: {
header: "bg-background-dimmed",
headerCell: "px-3 py-2.5 pb-3 text-sm",
cell: "group-hover/table-row:bg-background-bright group-has-[[tabindex='0']:focus]/table-row:bg-background-bright",
cellSize: "px-3 py-3",
cellText: "text-xs group-hover/table-row:text-text-bright",
stickyCell: "group-hover/table-row:bg-background-bright",
menuButton:
"bg-background-dimmed group-hover/table-row:bg-background-bright group-hover/table-row:ring-grid-bright group-has-[[tabindex='0']:focus]/table-row:bg-background-bright",
menuButtonDivider: "group-hover/table-row:border-grid-bright",
rowSelected: "bg-background-hover group-hover/table-row:bg-background-hover",
},
"compact/mono": {
header: "bg-background-dimmed",
headerCell: "px-2 py-1.5 text-sm",
cell: "group-hover/table-row:bg-background-bright group-has-[[tabindex='0']:focus]/table-row:bg-background-bright",
cellSize: "px-2 py-1.5",
cellText: "text-xs font-mono group-hover/table-row:text-text-bright",
stickyCell: "group-hover/table-row:bg-background-bright",
menuButton:
"bg-background-dimmed group-hover/table-row:bg-background-bright group-hover/table-row:ring-grid-bright group-has-[[tabindex='0']:focus]/table-row:bg-background-bright",
menuButtonDivider: "group-hover/table-row:border-grid-bright",
rowSelected: "bg-background-hover group-hover/table-row:bg-background-hover",
},
} as const;
export type TableVariant = keyof typeof variants;
type TableProps = {
containerClassName?: string;
className?: string;
children: ReactNode;
fullWidth?: boolean;
showTopBorder?: boolean;
stickyHeader?: boolean;
};
// Add TableContext
const TableContext = createContext<{ variant: TableVariant }>({ variant: "dimmed" });
export const Table = forwardRef<HTMLTableElement, TableProps & { variant?: TableVariant }>(
(
{
className,
containerClassName,
children,
fullWidth,
variant = "dimmed",
showTopBorder = true,
stickyHeader = false,
},
ref
) => {
return (
<TableContext.Provider value={{ variant }}>
<div
className={cn(
"whitespace-nowrap scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control",
stickyHeader ? "overflow-visible" : "overflow-x-auto",
showTopBorder && "border-t",
containerClassName,
fullWidth && "w-full"
)}
>
<table ref={ref} className={cn("w-full", className)}>
{children}
</table>
</div>
</TableContext.Provider>
);
}
);
type TableHeaderProps = {
className?: string;
children: ReactNode;
};
export const TableHeader = forwardRef<HTMLTableSectionElement, TableHeaderProps>(
({ className, children }, ref) => {
const { variant } = useContext(TableContext);
return (
<thead
ref={ref}
className={cn(
"safari-only sticky top-0 z-10 after:absolute after:bottom-0 after:left-0 after:right-0 after:h-px after:bg-grid-bright supports-[(-webkit-hyphens:none)]:after:content-none",
variants[variant].header,
className
)}
>
{children}
</thead>
);
}
);
type TableBodyProps = {
className?: string;
children?: ReactNode;
style?: React.CSSProperties;
};
export const TableBody = forwardRef<HTMLTableSectionElement, TableBodyProps>(
({ className, children, style }, ref) => {
return (
<tbody ref={ref} className={cn("relative overflow-y-auto", className)} style={style}>
{children}
</tbody>
);
}
);
type TableRowProps = JSX.IntrinsicElements["tr"] & {
className?: string;
children: ReactNode;
disabled?: boolean;
isSelected?: boolean;
};
export const TableRow = forwardRef<HTMLTableRowElement, TableRowProps>(
({ className, disabled, isSelected, children, ...props }, ref) => {
const { variant } = useContext(TableContext);
return (
<tr
ref={ref}
{...props}
className={cn(
"group/table-row relative w-full outline-hidden after:absolute after:bottom-0 after:left-3 after:right-0 after:h-px after:bg-grid-dimmed",
isSelected && variants[variant].rowSelected,
disabled && "opacity-50",
className
)}
>
{children}
</tr>
);
}
);
type TableCellBasicProps = {
className?: string;
alignment?: "left" | "center" | "right";
children?: ReactNode;
colSpan?: number;
};
type TableHeaderCellProps = TableCellBasicProps & {
hiddenLabel?: boolean;
tooltip?: ReactNode;
disableTooltipHoverableContent?: boolean;
};
export const TableHeaderCell = forwardRef<HTMLTableCellElement, TableHeaderCellProps>(
(
{
className,
alignment = "left",
children,
colSpan,
hiddenLabel = false,
tooltip,
disableTooltipHoverableContent = false,
},
ref
) => {
const { variant } = useContext(TableContext);
let alignmentClassName = "text-left";
switch (alignment) {
case "center":
alignmentClassName = "text-center";
break;
case "right":
alignmentClassName = "text-right";
break;
}
const [isHovered, setIsHovered] = useState(false);
return (
<th
ref={ref}
scope="col"
className={cn(
"align-middle font-medium text-text-bright",
variants[variant].headerCell,
alignmentClassName,
className
)}
colSpan={colSpan}
tabIndex={-1}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
{hiddenLabel ? (
<span className="sr-only">{children}</span>
) : tooltip ? (
<div
className={cn("flex items-center gap-1", {
"justify-center": alignment === "center",
"justify-end": alignment === "right",
})}
>
{children}
<InfoIconTooltip
content={tooltip}
contentClassName="normal-case tracking-normal"
enabled={isHovered}
disableHoverableContent={disableTooltipHoverableContent}
/>
</div>
) : (
children
)}
</th>
);
}
);
type TableCellProps = TableCellBasicProps & {
to?: string;
onClick?: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
hasAction?: boolean;
isSticky?: boolean;
actionClassName?: string;
rowHoverStyle?: string;
isSelected?: boolean;
isTabbableCell?: boolean;
children?: ReactNode;
style?: React.CSSProperties;
};
export const TableCell = forwardRef<HTMLTableCellElement, TableCellProps>(
(
{
className,
actionClassName,
alignment = "left",
children,
colSpan,
to,
onClick,
hasAction = false,
isSticky = false,
isSelected,
isTabbableCell = false,
style,
},
ref
) => {
let alignmentClassName = "text-left";
switch (alignment) {
case "center":
alignmentClassName = "text-center";
break;
case "right":
alignmentClassName = "text-right";
break;
}
const { variant } = useContext(TableContext);
const flexClasses = cn(
"flex w-full whitespace-nowrap items-center text-text-dimmed",
variants[variant].cellSize,
variants[variant].cellText,
alignment === "left"
? "justify-start text-left"
: alignment === "center"
? "justify-center text-center"
: "justify-end text-right"
);
return (
<td
ref={ref}
className={cn(
"safari-only text-xs text-text-dimmed has-[[tabindex='0']:focus]:before:absolute has-[[tabindex='0']:focus]:before:-top-px has-[[tabindex='0']:focus]:before:left-0 has-[[tabindex='0']:focus]:before:h-px has-[[tabindex='0']:focus]:before:w-3 has-[[tabindex='0']:focus]:before:bg-grid-dimmed has-[[tabindex='0']:focus]:after:absolute has-[[tabindex='0']:focus]:after:bottom-0 has-[[tabindex='0']:focus]:after:left-0 has-[[tabindex='0']:focus]:after:right-0 has-[[tabindex='0']:focus]:after:h-px has-[[tabindex='0']:focus]:after:bg-grid-dimmed",
variants[variant].cellText,
variants[variant].cell,
to || onClick || hasAction
? "cursor-pointer"
: cn("cursor-default align-middle", variants[variant].cellSize),
!to && !onClick && alignmentClassName,
isSticky && "[&:has([data-hidden-buttons])]:w-auto sticky right-0 bg-background-dimmed",
isSticky && variants[variant].stickyCell,
isSelected && variants[variant].rowSelected,
!isSelected &&
"group-hover/table-row:before:absolute group-hover/table-row:before:left-0 group-hover/table-row:before:-top-px group-hover/table-row:before:h-px group-hover/table-row:before:w-3 group-hover/table-row:before:bg-background-hover group-hover/table-row:after:absolute group-hover/table-row:after:bottom-0 group-hover/table-row:after:left-0 group-hover/table-row:after:h-px group-hover/table-row:after:w-3 group-hover/table-row:after:bg-background-hover group-focus-visible/table-row:bg-background-bright",
className
)}
colSpan={colSpan}
style={style}
>
{to ? (
<Link
to={to}
className={cn("cursor-pointer focus:outline-hidden", flexClasses, actionClassName)}
tabIndex={isTabbableCell ? 0 : -1}
>
{children}
</Link>
) : onClick ? (
<button
onClick={onClick}
className={cn("cursor-pointer focus:outline-hidden", flexClasses, actionClassName)}
tabIndex={isTabbableCell ? 0 : -1}
>
{children}
</button>
) : (
<>{children}</>
)}
</td>
);
}
);
type CopyableTableCellProps = TableCellProps & {
value: string;
};
export const CopyableTableCell = forwardRef<HTMLTableCellElement, CopyableTableCellProps>(
({ value, children, className, ...props }, ref) => {
const [isHovered, setIsHovered] = useState(false);
const { copy, copied } = useCopy(value);
return (
<TableCell ref={ref} className={className} {...props}>
<div
className="relative flex items-center"
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
{children}
{isHovered && (
<span
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
copy();
}}
className="absolute -right-2 top-1/2 z-10 flex -translate-y-1/2 cursor-pointer"
>
<SimpleTooltip
button={
<span
className={cn(
"flex size-6 items-center justify-center rounded border border-border-bright bg-background-hover",
copied
? "text-green-500"
: "text-text-dimmed hover:border-border-bright hover:bg-background-raised hover:text-text-bright"
)}
>
{copied ? (
<ClipboardCheckIcon className="size-3.5" />
) : (
<ClipboardIcon className="size-3.5" />
)}
</span>
}
content={copied ? "Copied!" : "Copy"}
disableHoverableContent
/>
</span>
)}
</div>
</TableCell>
);
}
);
export const TableCellChevron = forwardRef<
HTMLTableCellElement,
{
className?: string;
to?: string;
children?: ReactNode;
isSticky?: boolean;
onClick?: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
}
>(({ className, to, children, isSticky, onClick }, ref) => {
return (
<TableCell
className={className}
isSticky={isSticky}
to={to}
onClick={onClick}
ref={ref}
alignment="right"
>
{children}
<ChevronRightIcon className="size-4 text-text-dimmed transition group-hover:text-text-bright" />
</TableCell>
);
});
export const TableCellMenu = forwardRef<
HTMLTableCellElement,
TableCellProps & {
className?: string;
isSticky?: boolean;
onClick?: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
visibleButtons?: ReactNode;
hiddenButtons?: ReactNode;
popoverContent?: ReactNode | ((close: () => void) => ReactNode);
children?: ReactNode;
isSelected?: boolean;
}
>(
(
{
className,
isSticky,
onClick,
visibleButtons,
hiddenButtons,
popoverContent,
children,
isSelected,
},
ref
) => {
const [isOpen, setIsOpen] = useState(false);
const { variant } = useContext(TableContext);
const resolvedContent =
typeof popoverContent === "function"
? popoverContent(() => setIsOpen(false))
: popoverContent;
return (
<TableCell
className={className}
isSticky={isSticky}
onClick={onClick}
ref={ref}
alignment="right"
hasAction={true}
isSelected={isSelected}
>
<div className="relative h-full p-1">
<div
className={cn(
"absolute right-0 top-1/2 mr-1 flex -translate-y-1/2 items-center justify-end gap-0.5 rounded-[0.25rem] p-0.5 group-hover/table-row:ring-1",
variants[variant].menuButton
)}
>
{/* Hidden buttons that show on hover */}
{hiddenButtons && (
<div
data-hidden-buttons
className={cn(
"hidden group-hover/table-row:block",
popoverContent && "pr-0.5 group-hover/table-row:border-r",
variants[variant].menuButtonDivider
)}
>
<div className={cn("flex items-center gap-x-0.5 divide-x divide-grid-bright")}>
{hiddenButtons}
</div>
</div>
)}
{/* Always visible buttons */}
{visibleButtons}
{/* Always visible popover with ellipsis trigger */}
{resolvedContent && (
<Popover open={isOpen} onOpenChange={(open) => setIsOpen(open)}>
<PopoverVerticalEllipseTrigger
isOpen={isOpen}
className="duration-0 group-hover/table-row:text-text-bright"
/>
<PopoverContent
className="min-w-40 max-w-80 overflow-y-auto p-0 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control"
align="end"
>
{typeof popoverContent === "function" ? (
resolvedContent
) : (
<div className="flex flex-col gap-1 p-1">{resolvedContent}</div>
)}
</PopoverContent>
</Popover>
)}
</div>
</div>
</TableCell>
);
}
);
type TableBlankRowProps = {
className?: string;
colSpan: number;
children?: ReactNode;
};
export const TableBlankRow = forwardRef<HTMLTableRowElement, TableBlankRowProps>(
({ children, colSpan, className }, ref) => {
return (
<tr ref={ref}>
<td colSpan={colSpan} className={cn("py-6 text-center text-sm", className)}>
{children}
</td>
</tr>
);
}
);
@@ -0,0 +1,230 @@
import { NavLink } from "@remix-run/react";
import { motion } from "framer-motion";
import { type ReactNode, useRef } from "react";
import { type ShortcutDefinition, useShortcutKeys } from "~/hooks/useShortcutKeys";
import { cn } from "~/utils/cn";
import { ShortcutKey } from "./ShortcutKey";
export type Variants = "underline" | "pipe-divider" | "segmented";
export type TabsProps = {
tabs: {
label: string;
to: string;
end?: boolean;
}[];
className?: string;
layoutId: string;
variant?: Variants;
};
export function Tabs({ tabs, className, layoutId, variant = "underline" }: TabsProps) {
return (
<TabContainer className={className} variant={variant}>
{tabs.map((tab, index) => (
<TabLink
key={index}
to={tab.to}
layoutId={layoutId}
variant={variant}
end={tab.end ?? true}
>
{tab.label}
</TabLink>
))}
</TabContainer>
);
}
export function TabContainer({
children,
className,
variant = "underline",
}: {
children: ReactNode;
className?: string;
variant?: Variants;
}) {
if (variant === "segmented") {
return (
<div
className={cn(
"relative flex h-10 items-center rounded bg-background-raised/50 p-1",
className
)}
>
{children}
</div>
);
}
if (variant === "underline") {
return (
<div className={cn(`flex gap-x-6 border-b border-grid-bright`, className)}>{children}</div>
);
}
return <div className={cn(`flex`, className)}>{children}</div>;
}
export function TabLink({
to,
children,
layoutId,
variant = "underline",
end = true,
}: {
to: string;
children: ReactNode;
layoutId: string;
variant?: Variants;
end?: boolean;
}) {
if (variant === "segmented") {
return (
<NavLink
to={to}
className="group relative flex h-full grow items-center justify-center focus-custom"
end={end}
>
{({ isActive, isPending }) => {
const active = isActive || isPending;
return (
<>
<div className="relative z-10 flex h-full w-full items-center justify-center px-3 py-[0.13rem]">
<span
className={cn(
"text-sm transition duration-200",
active
? "text-text-bright"
: "text-text-dimmed transition group-hover:text-text-bright"
)}
>
{children}
</span>
</div>
{active && (
<motion.div
layoutId={layoutId}
transition={{ duration: 0.4, type: "spring" }}
className="absolute inset-0 rounded-[2px] border border-border-brightest/50 bg-surface-control"
/>
)}
</>
);
}}
</NavLink>
);
}
if (variant === "pipe-divider") {
return (
<NavLink
to={to}
className="group flex flex-col items-center border-r border-grid-bright px-2 pt-1 focus-custom first:pl-0 last:border-none"
end={end}
>
{({ isActive, isPending }) => {
const active = isActive || isPending;
return (
<span
className={cn(
"text-sm transition duration-200",
active ? "text-text-link" : "text-text-dimmed transition hover:text-text-bright"
)}
>
{children}
</span>
);
}}
</NavLink>
);
}
// underline variant (default)
return (
<NavLink to={to} className="group flex flex-col items-center pt-1 focus-custom" end={end}>
{({ isActive, isPending }) => {
return (
<>
<span
className={cn(
"text-sm transition duration-200",
isActive || isPending
? "text-text-bright"
: "text-text-dimmed hover:text-text-bright"
)}
>
{children}
</span>
{isActive || isPending ? (
<motion.div
layoutId={layoutId}
transition={{ type: "spring", stiffness: 500, damping: 30 }}
className="mt-1 h-0.5 w-full bg-indigo-500"
/>
) : (
<div className="mt-1 h-0.5 w-full bg-surface-control-active opacity-0 transition duration-200 group-hover:opacity-100" />
)}
</>
);
}}
</NavLink>
);
}
export function TabButton({
isActive,
layoutId,
shortcut,
...props
}: {
isActive: boolean;
shortcut?: ShortcutDefinition;
layoutId: string;
} & React.ButtonHTMLAttributes<HTMLButtonElement>) {
const ref = useRef<HTMLButtonElement>(null);
if (shortcut) {
useShortcutKeys({
shortcut: shortcut,
action: () => {
if (ref.current) {
ref.current.click();
}
},
disabled: props.disabled,
});
}
return (
<button
className={cn(
"group flex flex-col items-center pt-1 focus-custom",
props.className,
props.disabled && "pointer-events-none opacity-50"
)}
type="button"
ref={ref}
{...props}
>
<>
<div className="flex items-center gap-1">
<span className={"text-sm transition duration-200 text-text-bright"}>
{props.children}
</span>
{shortcut && <ShortcutKey className={cn("")} shortcut={shortcut} variant={"small"} />}
</div>
{isActive ? (
<motion.div
layoutId={layoutId}
transition={{ type: "spring", stiffness: 500, damping: 30 }}
className="mt-1 h-0.5 w-full bg-indigo-500"
/>
) : (
<div className="mt-1 h-0.5 w-full bg-surface-control-active opacity-0 transition duration-200 group-hover:opacity-100" />
)}
</>
</button>
);
}
@@ -0,0 +1,16 @@
import { cn } from "~/utils/cn";
type TextAreaProps = React.TextareaHTMLAttributes<HTMLTextAreaElement> & {};
export function TextArea({ className, rows, ...props }: TextAreaProps) {
return (
<textarea
{...props}
rows={rows ?? 6}
className={cn(
"placeholder:text-muted-foreground w-full rounded border border-background-bright bg-background-hover px-3 text-sm text-text-bright transition focus-custom file:border-0 file:bg-transparent file:text-base file:font-medium hover:border-border-bright hover:bg-secondary disabled:cursor-not-allowed disabled:opacity-50",
className
)}
/>
);
}
@@ -0,0 +1,94 @@
import { Link } from "@remix-run/react";
import { cn } from "~/utils/cn";
import { Icon, type RenderIcon } from "./Icon";
import { useRef } from "react";
import { type ShortcutDefinition, useShortcutKeys } from "~/hooks/useShortcutKeys";
import { ShortcutKey } from "./ShortcutKey";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./Tooltip";
const variations = {
primary:
"text-indigo-500 transition hover:text-indigo-400 inline-flex gap-0.5 items-center group focus-visible:focus-custom",
secondary:
"text-text-dimmed transition hover:text-text-bright inline-flex gap-0.5 items-center group focus-visible:focus-custom",
} as const;
type TextLinkProps = {
href?: string;
to?: string;
className?: string;
trailingIcon?: RenderIcon;
trailingIconClassName?: string;
variant?: keyof typeof variations;
children: React.ReactNode;
shortcut?: ShortcutDefinition;
hideShortcutKey?: boolean;
tooltip?: React.ReactNode;
} & React.AnchorHTMLAttributes<HTMLAnchorElement>;
export function TextLink({
href,
to,
children,
className,
trailingIcon,
trailingIconClassName,
variant = "primary",
shortcut,
hideShortcutKey,
tooltip,
...props
}: TextLinkProps) {
const innerRef = useRef<HTMLAnchorElement>(null);
const classes = variations[variant];
if (shortcut) {
useShortcutKeys({
shortcut: shortcut,
action: () => {
if (innerRef.current) {
innerRef.current.click();
}
},
});
}
const renderShortcutKey = () =>
shortcut &&
!hideShortcutKey && <ShortcutKey className="ml-1.5" shortcut={shortcut} variant="small" />;
const linkContent = (
<>
{children}{" "}
{trailingIcon && <Icon icon={trailingIcon} className={cn("size-4", trailingIconClassName)} />}
{shortcut && !tooltip && renderShortcutKey()}
</>
);
const linkElement = to ? (
<Link ref={innerRef} to={to} className={cn(classes, className)} {...props}>
{linkContent}
</Link>
) : href ? (
<a ref={innerRef} href={href} className={cn(classes, className)} {...props}>
{linkContent}
</a>
) : (
<span>Need to define a path or href</span>
);
if (tooltip) {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>{linkElement}</TooltipTrigger>
<TooltipContent className="text-dimmed flex items-center gap-3 py-1.5 pl-2.5 pr-3 text-xs">
{tooltip} {shortcut && renderShortcutKey()}
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
return linkElement;
}
@@ -0,0 +1,215 @@
import type { ComponentPropsWithoutRef, ReactNode } from "react";
import { Fragment, createContext, useCallback, useContext, useRef, useState } from "react";
import { inverseLerp, lerp } from "~/utils/lerp";
interface MousePosition {
x: number;
y: number;
}
const MousePositionContext = createContext<MousePosition | undefined>(undefined);
export function MousePositionProvider({ children }: { children: ReactNode }) {
const ref = useRef<HTMLDivElement>(null);
const [position, setPosition] = useState<MousePosition | undefined>(undefined);
const handleMouseMove = useCallback(
(e: React.MouseEvent) => {
if (!ref.current) {
setPosition(undefined);
return;
}
const { top, left, width, height } = ref.current.getBoundingClientRect();
const x = (e.clientX - left) / width;
const y = (e.clientY - top) / height;
if (x < 0 || x > 1 || y < 0 || y > 1) {
setPosition(undefined);
return;
}
setPosition({ x, y });
},
[ref.current]
);
return (
<div
ref={ref}
onMouseEnter={handleMouseMove}
onMouseLeave={() => setPosition(undefined)}
onMouseMove={handleMouseMove}
style={{ width: "100%", height: "100%" }}
>
<MousePositionContext.Provider value={position}>{children}</MousePositionContext.Provider>
</div>
);
}
export const useMousePosition = () => {
return useContext(MousePositionContext);
};
type TimelineContextState = {
startMs: number;
durationMs: number;
};
const TimelineContext = createContext<TimelineContextState>({} as TimelineContextState);
function useTimeline() {
return useContext(TimelineContext);
}
type TimelineMousePositionContextState = { x: number; y: number } | undefined;
const _TimelineMousePositionContext = createContext<TimelineMousePositionContextState>(undefined);
export type RootProps = {
/** If the timeline doesn't start at zero. Doesn't impact layout but gives you the times back */
startMs?: number;
durationMs: number;
/** A number between 0 and 1, determines the width between min and max */
scale: number;
minWidth: number;
maxWidth: number;
children?: ReactNode;
className?: string;
};
/** The main element that determines the dimensions for all sub-elements */
export function Root({
startMs = 0,
durationMs,
scale,
minWidth,
maxWidth,
children,
className,
}: RootProps) {
const pixelWidth = calculatePixelWidth(minWidth, maxWidth, scale);
return (
<TimelineContext.Provider value={{ startMs, durationMs }}>
<div
className={className}
style={{
position: "relative",
width: `${pixelWidth}px`,
}}
>
<MousePositionProvider>{children}</MousePositionProvider>
</div>
</TimelineContext.Provider>
);
}
export type RowProps = ComponentPropsWithoutRef<"div">;
/** This simply acts as a container, with position relative.
* This allows you to nest "Rows" and put heights on them */
export function Row({ className, children, ...props }: RowProps) {
return (
<div {...props} className={className} style={{ ...props.style, position: "relative" }}>
{children}
</div>
);
}
export type PointProps = {
ms: number;
className?: string;
children?: (ms: number) => ReactNode;
};
/** A point in time, it has no duration */
export function Point({ ms, className, children }: PointProps) {
const { startMs, durationMs } = useTimeline();
const position = inverseLerp(startMs, startMs + durationMs, ms);
return (
<div
className={className}
style={{
position: "absolute",
left: `${position * 100}%`,
}}
>
{children && children(ms)}
</div>
);
}
export type SpanProps = {
startMs: number;
durationMs: number;
className?: string;
children?: ReactNode;
};
/** As span of time with a start and duration */
export function Span({ startMs, durationMs, className, children }: SpanProps) {
const { startMs: rootStartMs, durationMs: rootDurationMs } = useTimeline();
const position = inverseLerp(rootStartMs, rootStartMs + rootDurationMs, startMs);
const width =
inverseLerp(rootStartMs, rootStartMs + rootDurationMs, startMs + durationMs) - position;
return (
<div
className={className}
style={{
position: "absolute",
left: `${position * 100}%`,
width: `${width * 100}%`,
}}
>
{children}
</div>
);
}
export type EquallyDistributeProps = {
count: number;
children: (ms: number, index: number) => ReactNode;
};
/** Render a child equally distributed across the duration */
export function EquallyDistribute({ count, children }: EquallyDistributeProps) {
const { startMs, durationMs } = useTimeline();
return (
<>
{Array.from({ length: count }).map((_, index) => {
const ms = startMs + (durationMs / (count - 1)) * index;
return <Fragment key={index}>{children(ms, index)}</Fragment>;
})}
</>
);
}
export type FollowCursorProps = {
children: (ms: number) => ReactNode;
};
/** Renders a child that follows the cursor */
export function FollowCursor({ children }: FollowCursorProps) {
const { startMs, durationMs } = useTimeline();
const relativeMousePosition = useMousePosition();
const ms = relativeMousePosition?.x
? lerp(startMs, startMs + durationMs, relativeMousePosition.x)
: undefined;
if (ms === undefined) return null;
return (
<div
style={{
position: "absolute",
top: 0,
left: relativeMousePosition ? `${relativeMousePosition?.x * 100}%` : 0,
height: "100%",
pointerEvents: "none",
}}
>
{children(ms)}
</div>
);
}
/** Gives the total width of the root */
function calculatePixelWidth(minWidth: number, maxWidth: number, scale: number) {
return lerp(minWidth, maxWidth, scale);
}
@@ -0,0 +1,174 @@
import { EnvelopeIcon, ExclamationCircleIcon, XMarkIcon } from "@heroicons/react/20/solid";
import { CheckCircleIcon } from "@heroicons/react/24/solid";
import { useSearchParams } from "@remix-run/react";
import { useEffect, useMemo } from "react";
import { useTypedLoaderData } from "remix-typedjson";
import { Toaster, toast } from "sonner";
import { type ToastMessageAction } from "~/models/message.server";
import { type loader } from "~/root";
import { cn } from "~/utils/cn";
import { Button, LinkButton } from "./Buttons";
import { Header2 } from "./Headers";
import { Paragraph } from "./Paragraph";
const defaultToastDuration = 5000;
const permanentToastDuration = 60 * 60 * 24 * 1000;
export function Toast() {
const { toastMessage } = useTypedLoaderData<typeof loader>();
useEffect(() => {
if (!toastMessage) {
return;
}
const { message, type, options } = toastMessage;
const ephemeral = options.action ? false : options.ephemeral;
toast.custom(
(t) => (
<ToastUI
variant={type}
message={message}
t={t as string}
title={options.title}
action={options.action}
/>
),
{
duration: ephemeral ? defaultToastDuration : permanentToastDuration,
}
);
}, [toastMessage]);
return <Toaster />;
}
export function useToast() {
return useMemo(
() => ({
success(message: string, options?: { title?: string; ephemeral?: boolean }) {
const ephemeral = options?.ephemeral ?? true;
toast.custom(
(t) => (
<ToastUI variant="success" message={message} t={t as string} title={options?.title} />
),
{ duration: ephemeral ? defaultToastDuration : permanentToastDuration }
);
},
error(message: string, options?: { title?: string; ephemeral?: boolean }) {
const ephemeral = options?.ephemeral ?? true;
toast.custom(
(t) => (
<ToastUI variant="error" message={message} t={t as string} title={options?.title} />
),
{ duration: ephemeral ? defaultToastDuration : permanentToastDuration }
);
},
}),
[]
);
}
export function ToastUI({
variant,
message,
t,
toastWidth = 356, // Default width, matches what sonner provides by default
title,
action,
}: {
variant: "error" | "success";
message: string;
t: string;
toastWidth?: string | number;
title?: string;
action?: ToastMessageAction;
}) {
return (
<div
className={cn(
"self-end rounded-md border border-grid-bright bg-background-dimmed",
variant === "success" && "border-success",
variant === "error" && "border-error"
)}
style={{
width: toastWidth,
}}
>
<div
className={cn("flex w-full gap-2 rounded-lg p-3", title ? "items-start" : "items-center")}
>
{variant === "success" ? (
<CheckCircleIcon className={cn("size-4 min-w-4 text-success", title && "mt-1")} />
) : (
<ExclamationCircleIcon className={cn("size-4 min-w-4 text-error", title && "mt-1")} />
)}
<div className="flex flex-col">
{title && <Header2 className="pt-0">{title}</Header2>}
<Paragraph
variant={title ? "small/dimmed" : "small/bright"}
className={title ? "pb-1 pt-0.5" : ""}
>
{message}
</Paragraph>
<Action action={action} toastId={t} className="my-2" />
</div>
<button
className={cn(
"-mr-1 ms-auto rounded p-2 text-text-dimmed transition hover:text-text-bright",
title && "-mt-1"
)}
onClick={() => toast.dismiss(t)}
>
<XMarkIcon className="size-4" />
</button>
</div>
</div>
);
}
function Action({
action,
toastId,
className,
}: {
action?: ToastMessageAction;
toastId: string;
className?: string;
}) {
const [_, setSearchParams] = useSearchParams();
if (!action) return null;
switch (action.action.type) {
case "link": {
return (
<LinkButton
className={className}
variant={action.variant ?? "secondary/small"}
to={action.action.path}
>
{action.label}
</LinkButton>
);
}
case "help": {
const feedbackType = action.action.feedbackType;
return (
<Button
className={className}
variant={action.variant ?? "secondary/small"}
LeadingIcon={EnvelopeIcon}
onClick={() => {
setSearchParams({
feedbackPanel: feedbackType,
});
toast.dismiss(toastId);
}}
>
{action.label}
</Button>
);
}
}
}
@@ -0,0 +1,143 @@
import { InformationCircleIcon } from "@heroicons/react/20/solid";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import * as React from "react";
import { cn } from "~/utils/cn";
const variantClasses = {
basic:
"bg-background-bright border border-grid-bright rounded px-3 py-2 text-sm text-text-bright shadow-md fade-in-50",
dark: "bg-background-dimmed border border-grid-bright rounded px-3 py-2 text-sm text-text-bright shadow-md fade-in-50",
};
type Variant = keyof typeof variantClasses;
const TooltipProvider = TooltipPrimitive.Provider;
const TooltipArrow = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Arrow>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Arrow>
>(({ ...props }, ref) => <TooltipPrimitive.Arrow className="fill-popover z-50" {...props} />);
TooltipArrow.displayName = TooltipPrimitive.Arrow.displayName;
const Tooltip = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Root>
>(({ delayDuration = 0, ...props }, ref) => (
<TooltipPrimitive.Root delayDuration={delayDuration} {...props} />
));
Tooltip.displayName = TooltipPrimitive.Root.displayName;
const TooltipTrigger = TooltipPrimitive.Trigger;
type TooltipContentProps = {
variant?: Variant;
} & React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>;
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
TooltipContentProps
>(({ className, sideOffset = 4, variant = "basic", ...props }, ref) => (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden animate-in data-[side=bottom]:slide-in-from-top-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 data-[side=top]:slide-in-from-bottom-1 focus-visible:outline-hidden",
variantClasses[variant],
className
)}
{...props}
/>
</TooltipPrimitive.Portal>
));
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
function SimpleTooltip({
button,
content,
side,
hidden,
variant,
disableHoverableContent = false,
className,
buttonClassName,
buttonStyle,
asChild = false,
sideOffset,
open,
onOpenChange,
delayDuration,
}: {
button: React.ReactNode;
content: React.ReactNode;
side?: React.ComponentProps<typeof TooltipContent>["side"];
hidden?: boolean;
variant?: Variant;
disableHoverableContent?: boolean;
className?: string;
buttonClassName?: string;
buttonStyle?: React.CSSProperties;
asChild?: boolean;
sideOffset?: number;
open?: boolean;
onOpenChange?: (open: boolean) => void;
delayDuration?: number;
}) {
return (
<TooltipProvider disableHoverableContent={disableHoverableContent}>
<Tooltip open={open} onOpenChange={onOpenChange} delayDuration={delayDuration}>
<TooltipTrigger
type={asChild ? undefined : "button"}
tabIndex={-1}
className={cn(!asChild && "h-fit", buttonClassName)}
style={buttonStyle}
asChild={asChild}
>
{button}
</TooltipTrigger>
<TooltipContent
side={side}
hidden={hidden}
sideOffset={sideOffset}
className={cn("text-xs", className)}
variant={variant}
>
{content}
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
export function InfoIconTooltip({
content,
buttonClassName,
contentClassName,
variant = "basic",
disableHoverableContent = false,
enabled = true,
}: {
content: React.ReactNode;
buttonClassName?: string;
contentClassName?: string;
variant?: Variant;
disableHoverableContent?: boolean;
enabled?: boolean;
}) {
const icon = (
<InformationCircleIcon className={cn("size-3.5 text-text-dimmed flex-0", buttonClassName)} />
);
if (!enabled) return icon;
return (
<SimpleTooltip
button={icon}
content={content}
variant={variant}
className={contentClassName}
disableHoverableContent={disableHoverableContent}
/>
);
}
export { SimpleTooltip, Tooltip, TooltipArrow, TooltipContent, TooltipProvider, TooltipTrigger };
@@ -0,0 +1,104 @@
import type { VirtualElement as IVirtualElement } from "@popperjs/core";
import type { ReactNode } from "react";
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { usePopper } from "react-popper";
import { useEvent } from "react-use";
import useLazyRef from "~/hooks/useLazyRef";
// Recharts 3.x will have portal support, but until then we're using this:
//https://github.com/recharts/recharts/issues/2458#issuecomment-1063463873
export interface PopperPortalProps {
active?: boolean;
children: ReactNode;
}
export default function TooltipPortal({ active = true, children }: PopperPortalProps) {
const [portalElement, setPortalElement] = useState<HTMLDivElement>();
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>();
const virtualElementRef = useLazyRef(() => new VirtualElement());
const { styles, attributes, update } = usePopper(
virtualElementRef.current,
popperElement,
POPPER_OPTIONS
);
useEffect(() => {
const el = document.createElement("div");
document.body.appendChild(el);
setPortalElement(el);
return () => el.remove();
}, []);
useEvent("mousemove", ({ clientX: x, clientY: y }) => {
virtualElementRef.current?.update(x, y);
if (!active) return;
update?.();
});
useEffect(() => {
if (!active) return;
update?.();
}, [active, update]);
if (!portalElement) return null;
return createPortal(
<div
ref={setPopperElement}
{...attributes.popper}
style={{
...styles.popper,
zIndex: 1000,
display: active ? "block" : "none",
}}
>
{children}
</div>,
portalElement
);
}
class VirtualElement implements IVirtualElement {
private rect = {
width: 0,
height: 0,
top: 0,
right: 0,
bottom: 0,
left: 0,
x: 0,
y: 0,
toJSON() {
return this;
},
};
update(x: number, y: number) {
this.rect.y = y;
this.rect.top = y;
this.rect.bottom = y;
this.rect.x = x;
this.rect.left = x;
this.rect.right = x;
}
getBoundingClientRect(): DOMRect {
return this.rect;
}
}
const POPPER_OPTIONS: Parameters<typeof usePopper>[2] = {
placement: "right-start",
modifiers: [
{
name: "offset",
options: {
offset: [8, 8],
},
},
],
};
@@ -0,0 +1,645 @@
import type { VirtualItem, Virtualizer } from "@tanstack/react-virtual";
import { useVirtualizer } from "@tanstack/react-virtual";
import { motion } from "framer-motion";
import type { MutableRefObject, RefObject } from "react";
import { useCallback, useEffect, useMemo, useReducer, useRef } from "react";
import { cn } from "~/utils/cn";
import type { NodeState, NodesState } from "./reducer";
import { reducer } from "./reducer";
import { concreteStateFromInput, selectedIdFromState } from "./utils";
export type TreeViewProps<TData> = {
tree: FlatTree<TData>;
parentClassName?: string;
renderNode: (params: {
node: FlatTreeItem<TData>;
state: NodeState;
index: number;
virtualizer: Virtualizer<HTMLElement, Element>;
virtualItem: VirtualItem;
}) => React.ReactNode;
nodes: UseTreeStateOutput["nodes"];
autoFocus?: boolean;
virtualizer: Virtualizer<HTMLElement, Element>;
parentRef?: MutableRefObject<HTMLElement | null>;
scrollRef?: MutableRefObject<HTMLElement | null>;
onScroll?: (scrollTop: number) => void;
} & Pick<UseTreeStateOutput, "getTreeProps" | "getNodeProps">;
export type GetTreePropsFn = UseTreeStateOutput["getTreeProps"];
export type GetNodePropsFn = UseTreeStateOutput["getNodeProps"];
export function TreeView<TData>({
tree,
renderNode,
nodes,
autoFocus = false,
getTreeProps,
getNodeProps,
parentClassName,
virtualizer,
parentRef,
scrollRef,
onScroll,
}: TreeViewProps<TData>) {
useEffect(() => {
if (autoFocus) {
parentRef?.current?.focus();
}
}, [autoFocus, parentRef?.current]);
const virtualItems = virtualizer.getVirtualItems();
// id -> node lookup so each virtual row resolves in O(1) instead of
// scanning the whole tree per row.
const nodesById = useMemo(() => {
const map = new Map<string, FlatTreeItem<TData>>();
for (const node of tree) {
map.set(node.id, node);
}
return map;
}, [tree]);
const scrollCallback = useCallback(
(event: Event) => {
if (!onScroll) return;
const target = event.target as HTMLElement;
onScroll?.(target.scrollTop);
},
[onScroll]
);
useEffect(() => {
//subscribe to scrollRef scroll event
if (!scrollRef?.current || onScroll === undefined) return;
scrollRef.current.addEventListener("scroll", scrollCallback);
return () => scrollRef.current?.removeEventListener("scroll", scrollCallback);
}, [scrollRef?.current]);
return (
<motion.div
ref={(element) => {
if (parentRef) {
parentRef.current = element;
}
if (scrollRef) {
scrollRef.current = element;
}
}}
className={cn(
"w-full overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control focus-within:outline-hidden",
parentClassName
)}
layoutScroll
{...getTreeProps()}
>
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
width: "100%",
position: "relative",
overflowY: "visible",
}}
>
<div
style={{
position: "absolute",
overflowY: "visible",
top: 0,
left: 0,
width: "100%",
transform: `translateY(${virtualItems.at(0)?.start ?? 0}px)`,
}}
>
{virtualItems.map((virtualItem) => {
const node = nodesById.get(virtualItem.key as string);
if (!node) return null;
const state = nodes[node.id];
if (!state) return null;
if (!state.visible) return null;
return (
<div
key={node.id}
data-index={virtualItem.index}
ref={virtualizer.measureElement}
className="overflow-clip"
{...getNodeProps(node.id)}
>
{renderNode({
node,
state,
index: virtualItem.index,
virtualizer: virtualizer,
virtualItem,
})}
</div>
);
})}
</div>
</div>
</motion.div>
);
}
export type Filter<TData, TFilterValue> = {
value?: TFilterValue;
fn: (value: TFilterValue, node: FlatTreeItem<TData>) => boolean;
};
type TreeStateHookProps<TData, TFilterValue> = {
tree: FlatTree<TData>;
selectedId?: string;
collapsedIds?: string[];
onSelectedIdChanged?: (selectedId: string | undefined) => void;
estimatedRowHeight: (params: {
node: FlatTreeItem<TData>;
state: NodeState;
index: number;
}) => number;
parentRef: RefObject<any>;
filter?: Filter<TData, TFilterValue>;
};
//this is so Framer Motion can be used to render the components
type HTMLAttributes = Omit<
React.HTMLAttributes<HTMLElement>,
"onAnimationStart" | "onDragStart" | "onDragEnd" | "onDrag"
>;
export type UseTreeStateOutput = {
selected: string | undefined;
nodes: NodesState;
virtualizer: Virtualizer<HTMLElement, Element>;
getTreeProps: () => HTMLAttributes;
getNodeProps: (id: string) => HTMLAttributes;
selectNode: (id: string, scrollToNode?: boolean) => void;
deselectNode: (id: string) => void;
deselectAllNodes: () => void;
toggleNodeSelection: (id: string, scrollToNode?: boolean) => void;
expandNode: (id: string, scrollToNode?: boolean) => void;
collapseNode: (id: string) => void;
toggleExpandNode: (id: string, scrollToNode?: boolean) => void;
expandAllBelowDepth: (depth: number) => void;
collapseAllBelowDepth: (depth: number) => void;
expandLevel: (level: number) => void;
collapseLevel: (level: number) => void;
toggleExpandLevel: (level: number) => void;
selectFirstVisibleNode: (scrollToNode?: boolean) => void;
selectLastVisibleNode: (scrollToNode?: boolean) => void;
selectNextVisibleNode: (scrollToNode?: boolean) => void;
selectPreviousVisibleNode: (scrollToNode?: boolean) => void;
selectParentNode: (scrollToNode?: boolean) => void;
scrollToNode: (id: string) => void;
};
export function useTree<TData, TFilterValue>({
tree,
selectedId,
collapsedIds,
onSelectedIdChanged,
parentRef,
estimatedRowHeight,
filter,
}: TreeStateHookProps<TData, TFilterValue>): UseTreeStateOutput {
const previousNodeCount = useRef(tree.length);
const previousSelectedId = useRef<string | undefined>(selectedId);
const [state, dispatch] = useReducer(
reducer,
concreteStateFromInput({ tree, selectedId, collapsedIds, filter })
);
// id -> index lookup so getNodeProps resolves in O(1) instead of scanning
// the whole tree per rendered row.
const treeIndexById = useMemo(() => {
const map = new Map<string, number>();
tree.forEach((node, index) => {
map.set(node.id, index);
});
return map;
}, [tree]);
//sync external selectedId prop into internal state
useEffect(() => {
const internalSelectedId = selectedIdFromState(state.nodes);
if (selectedId !== internalSelectedId) {
if (selectedId === undefined) {
dispatch({ type: "DESELECT_ALL_NODES" });
} else {
dispatch({
type: "SELECT_NODE",
payload: { id: selectedId, scrollToNode: false, scrollToNodeFn },
});
}
}
}, [selectedId]);
//fire onSelectedIdChanged()
useEffect(() => {
const selectedId = selectedIdFromState(state.nodes);
if (selectedId !== previousSelectedId.current) {
previousSelectedId.current = selectedId;
onSelectedIdChanged?.(selectedId);
}
}, [state.changes.selectedId]);
//update tree when the number of nodes changes
useEffect(() => {
if (tree.length !== previousNodeCount.current) {
previousNodeCount.current = tree.length;
dispatch({ type: "UPDATE_TREE", payload: { tree } });
}
}, [previousNodeCount.current, tree.length]);
//update the filter, if it's changed
const previousFilter = useRef(filter);
useEffect(() => {
//check if the value (not reference) of the filter is the same
const previousValue = previousFilter.current
? JSON.stringify(previousFilter.current.value)
: undefined;
const newValue = filter ? JSON.stringify(filter.value) : undefined;
previousFilter.current = filter;
if (previousValue !== newValue) {
dispatch({ type: "UPDATE_FILTER", payload: { filter } });
}
}, [filter?.value]);
const virtualizer = useVirtualizer({
count: state.visibleNodeIds.length,
getItemKey: (index) => state.visibleNodeIds[index],
getScrollElement: () => parentRef.current,
estimateSize: (index: number) => {
const treeItem = tree[index];
if (!treeItem) return 0;
return estimatedRowHeight({
node: treeItem,
state: state.nodes[treeItem.id],
index,
});
},
overscan: 50,
});
const scrollToNodeFn = useCallback(
(id: string) => {
const itemIndex = state.visibleNodeIds.findIndex((n) => n === id);
if (itemIndex !== -1) {
virtualizer.scrollToIndex(itemIndex, { align: "auto" });
}
},
[state]
);
const selectNode = useCallback(
(id: string, scrollToNode = true) => {
dispatch({ type: "SELECT_NODE", payload: { id, scrollToNode, scrollToNodeFn } });
},
[state]
);
const deselectNode = useCallback(
(id: string) => {
dispatch({ type: "DESELECT_NODE", payload: { id } });
},
[state]
);
const deselectAllNodes = useCallback(() => {
dispatch({ type: "DESELECT_ALL_NODES" });
}, [state]);
const toggleNodeSelection = useCallback(
(id: string, scrollToNode = true) => {
dispatch({ type: "TOGGLE_NODE_SELECTION", payload: { id, scrollToNode, scrollToNodeFn } });
},
[state]
);
const expandNode = useCallback(
(id: string, scrollToNode = true) => {
dispatch({ type: "EXPAND_NODE", payload: { id, scrollToNode, scrollToNodeFn } });
},
[state]
);
const collapseNode = useCallback(
(id: string) => {
dispatch({ type: "COLLAPSE_NODE", payload: { id } });
},
[state]
);
const toggleExpandNode = useCallback(
(id: string, scrollToNode = true) => {
dispatch({ type: "TOGGLE_EXPAND_NODE", payload: { id, scrollToNode, scrollToNodeFn } });
},
[state]
);
const selectFirstVisibleNode = useCallback(
(scrollToNode = true) => {
dispatch({
type: "SELECT_FIRST_VISIBLE_NODE",
payload: { scrollToNode, scrollToNodeFn },
});
},
[tree, state]
);
const selectLastVisibleNode = useCallback(
(scrollToNode = true) => {
dispatch({
type: "SELECT_LAST_VISIBLE_NODE",
payload: { scrollToNode, scrollToNodeFn },
});
},
[tree, state]
);
const selectNextVisibleNode = useCallback(
(scrollToNode = true) => {
dispatch({
type: "SELECT_NEXT_VISIBLE_NODE",
payload: { scrollToNode, scrollToNodeFn },
});
},
[state]
);
const selectPreviousVisibleNode = useCallback(
(scrollToNode = true) => {
dispatch({
type: "SELECT_PREVIOUS_VISIBLE_NODE",
payload: { scrollToNode, scrollToNodeFn },
});
},
[state]
);
const selectParentNode = useCallback(
(scrollToNode = true) => {
dispatch({
type: "SELECT_PARENT_NODE",
payload: { scrollToNode, scrollToNodeFn },
});
},
[state]
);
const expandAllBelowDepth = useCallback(
(depth: number) => {
dispatch({ type: "EXPAND_ALL_BELOW_DEPTH", payload: { depth } });
},
[state]
);
const collapseAllBelowDepth = useCallback(
(depth: number) => {
dispatch({ type: "COLLAPSE_ALL_BELOW_DEPTH", payload: { depth } });
},
[state]
);
const expandLevel = useCallback(
(level: number) => {
dispatch({ type: "EXPAND_LEVEL", payload: { level } });
},
[state]
);
const collapseLevel = useCallback(
(level: number) => {
dispatch({ type: "COLLAPSE_LEVEL", payload: { level } });
},
[state]
);
const toggleExpandLevel = useCallback(
(level: number) => {
dispatch({ type: "TOGGLE_EXPAND_LEVEL", payload: { level } });
},
[state]
);
const getTreeProps = useCallback(() => {
return {
role: "tree",
"aria-multiselectable": true,
tabIndex: -1,
onKeyDown: (e: React.KeyboardEvent<HTMLElement>) => {
if (e.defaultPrevented) {
return; // Do nothing if the event was already processed
}
switch (e.key) {
case "Home": {
selectFirstVisibleNode(true);
e.preventDefault();
break;
}
case "End": {
selectLastVisibleNode(true);
e.preventDefault();
break;
}
case "Down":
case "ArrowDown": {
selectNextVisibleNode(true);
e.preventDefault();
break;
}
case "Up":
case "ArrowUp": {
selectPreviousVisibleNode(true);
e.preventDefault();
break;
}
case "Left":
case "ArrowLeft": {
if (e.metaKey) {
return;
}
e.preventDefault();
const selected = selectedIdFromState(state.nodes);
if (selected) {
const treeNode = tree.find((node) => node.id === selected);
if (e.altKey) {
if (treeNode && treeNode.hasChildren) {
collapseLevel(treeNode.level);
}
break;
}
const shouldCollapse =
treeNode && treeNode.hasChildren && state.nodes[selected].expanded;
if (shouldCollapse) {
collapseNode(selected);
} else {
selectParentNode(true);
}
}
break;
}
case "Right":
case "ArrowRight": {
e.preventDefault();
const selected = selectedIdFromState(state.nodes);
if (selected) {
const treeNode = tree.find((node) => node.id === selected);
if (e.altKey) {
if (treeNode && treeNode.hasChildren) {
expandLevel(treeNode.level);
}
break;
}
expandNode(selected, true);
}
break;
}
case "Escape": {
deselectAllNodes();
e.preventDefault();
break;
}
}
},
};
}, [state]);
const getNodeProps = useCallback(
(id: string) => {
const node = state.nodes[id];
if (!node) return {};
const treeItemIndex = treeIndexById.get(id) ?? -1;
const treeItem = tree[treeItemIndex];
return {
"aria-expanded": node.expanded,
"aria-level": treeItem.level + 1,
role: "treeitem",
tabIndex: node.selected ? -1 : undefined,
};
},
[state, treeIndexById]
);
return {
selected: selectedIdFromState(state.nodes),
nodes: state.nodes,
getTreeProps,
getNodeProps,
selectNode,
deselectNode,
deselectAllNodes,
toggleNodeSelection,
expandNode,
collapseNode,
toggleExpandNode,
expandAllBelowDepth,
collapseAllBelowDepth,
expandLevel,
collapseLevel,
toggleExpandLevel,
selectFirstVisibleNode,
selectLastVisibleNode,
selectNextVisibleNode,
selectPreviousVisibleNode,
selectParentNode,
scrollToNode: scrollToNodeFn,
virtualizer,
};
}
/** An actual tree structure with custom data */
export type Tree<TData> = {
id: string;
runId?: string;
children?: Tree<TData>[];
data: TData;
};
/** A tree but flattened so it can easily be used for DOM elements */
export type FlatTreeItem<TData> = {
id: string;
parentId?: string | undefined;
runId?: string;
children: string[];
hasChildren: boolean;
/** The indentation level, the root is 0 */
level: number;
data: TData;
};
export type FlatTree<TData> = FlatTreeItem<TData>[];
export function flattenTree<TData>(tree: Tree<TData>): FlatTree<TData> {
const flatTree: FlatTree<TData> = [];
function flattenNode(node: Tree<TData>, parentId: string | undefined, level: number) {
const children = node.children?.map((child) => child.id) ?? [];
flatTree.push({
id: node.id,
parentId,
runId: node.runId,
children,
hasChildren: children.length > 0,
level,
data: node.data,
});
node.children?.forEach((child) => {
flattenNode(child, node.id, level + 1);
});
}
flattenNode(tree, undefined, 0);
return flatTree;
}
type FlatTreeWithoutChildren<TData> = {
id: string;
parentId: string | undefined;
runId?: string;
data: TData;
};
export function createTreeFromFlatItems<TData>(
withoutChildren: FlatTreeWithoutChildren<TData>[],
rootId: string
): Tree<TData> | undefined {
// Index items by id
const indexedItems: { [id: string]: Tree<TData> } = withoutChildren.reduce(
(acc, item) => {
acc[item.id] = { id: item.id, runId: item.runId, data: item.data, children: [] };
return acc;
},
{} as { [id: string]: Tree<TData> }
);
// Add items to parent's children array
withoutChildren.forEach((item) => {
const indexedItem = indexedItems[item.id];
if (item.parentId !== undefined) {
const parentItem = indexedItems[item.parentId];
if (parentItem) {
// If parent ID doesn't exist, this is also a root item
parentItem.children?.push(indexedItem);
}
}
});
return indexedItems[rootId];
}
@@ -0,0 +1,553 @@
import assertNever from "assert-never";
import type { Filter, FlatTree } from "./TreeView";
import {
applyFilterToState,
applyVisibility,
collapsedIdsFromState,
concreteStateFromInput,
firstVisibleNode,
generateChanges,
lastVisibleNode,
selectedIdFromState,
visibleNodes,
} from "./utils";
export type NodeState = {
selected: boolean;
expanded: boolean;
visible: boolean;
};
export type Changes = {
selectedId: string | undefined;
};
export type TreeState = {
tree: FlatTree<any>;
nodes: NodesState;
filteredNodes: NodesState;
changes: Changes;
filter: Filter<any, any> | undefined;
visibleNodeIds: string[];
};
export type NodesState = Record<string, NodeState>;
type ScrollToNodeFn = (id: string) => void;
type WithScrollToNode = {
scrollToNode: boolean;
scrollToNodeFn: ScrollToNodeFn;
};
type UpdateTreeAction = {
type: "UPDATE_TREE";
payload: {
tree: FlatTree<any>;
};
};
type SelectNodeAction = {
type: "SELECT_NODE";
payload: {
id: string;
} & WithScrollToNode;
};
type DeselectNodeAction = {
type: "DESELECT_NODE";
payload: {
id: string;
};
};
type DeselectAllNodesAction = {
type: "DESELECT_ALL_NODES";
};
type ToggleNodeSelection = {
type: "TOGGLE_NODE_SELECTION";
payload: {
id: string;
} & WithScrollToNode;
};
type ExpandNodeAction = {
type: "EXPAND_NODE";
payload: {
id: string;
} & WithScrollToNode;
};
type CollapseNodeAction = {
type: "COLLAPSE_NODE";
payload: {
id: string;
};
};
type ToggleExpandNodeAction = {
type: "TOGGLE_EXPAND_NODE";
payload: {
id: string;
} & WithScrollToNode;
};
type ExpandAllBelowDepthAction = {
type: "EXPAND_ALL_BELOW_DEPTH";
payload: {
depth: number;
};
};
type CollapseAllBelowDepthAction = {
type: "COLLAPSE_ALL_BELOW_DEPTH";
payload: {
depth: number;
};
};
type ExpandLevelAction = {
type: "EXPAND_LEVEL";
payload: {
level: number;
};
};
type CollapseLevelAction = {
type: "COLLAPSE_LEVEL";
payload: {
level: number;
};
};
type ToggleExpandLevelAction = {
type: "TOGGLE_EXPAND_LEVEL";
payload: {
level: number;
};
};
type SelectFirstVisibleNodeAction = {
type: "SELECT_FIRST_VISIBLE_NODE";
payload: {} & WithScrollToNode;
};
type SelectLastVisibleNodeAction = {
type: "SELECT_LAST_VISIBLE_NODE";
payload: {} & WithScrollToNode;
};
type SelectNextVisibleNodeAction = {
type: "SELECT_NEXT_VISIBLE_NODE";
payload: {} & WithScrollToNode;
};
type SelectPreviousVisibleNodeAction = {
type: "SELECT_PREVIOUS_VISIBLE_NODE";
payload: {} & WithScrollToNode;
};
type SelectParentNodeAction = {
type: "SELECT_PARENT_NODE";
payload: {} & WithScrollToNode;
};
type UpdateFilterAction = {
type: "UPDATE_FILTER";
payload: {
filter: Filter<any, any> | undefined;
};
};
export type Action =
| UpdateTreeAction
| SelectNodeAction
| DeselectNodeAction
| DeselectAllNodesAction
| ToggleNodeSelection
| ExpandNodeAction
| CollapseNodeAction
| ToggleExpandNodeAction
| ExpandAllBelowDepthAction
| CollapseAllBelowDepthAction
| ExpandLevelAction
| CollapseLevelAction
| ToggleExpandLevelAction
| SelectFirstVisibleNodeAction
| SelectLastVisibleNodeAction
| SelectNextVisibleNodeAction
| SelectPreviousVisibleNodeAction
| SelectParentNodeAction
| UpdateFilterAction;
export function reducer(state: TreeState, action: Action): TreeState {
switch (action.type) {
case "SELECT_NODE": {
//if the node was already selected, do nothing. The user needs to use deselectNode to deselect
const alreadySelected = state.nodes[action.payload.id]?.selected ?? false;
if (alreadySelected) {
return state;
}
const newNodes = Object.fromEntries(
Object.entries(state.nodes).map(([key, value]) => [key, { ...value, selected: false }])
);
newNodes[action.payload.id] = { ...newNodes[action.payload.id], selected: true };
if (action.payload.scrollToNode) {
action.payload.scrollToNodeFn(action.payload.id);
}
return applyFilterToState({
...state,
tree: state.tree,
nodes: newNodes,
changes: generateChanges(state.nodes, newNodes),
});
}
case "DESELECT_NODE": {
const nodes = {
...state.nodes,
[action.payload.id]: { ...state.nodes[action.payload.id], selected: false },
};
return applyFilterToState({
...state,
nodes,
changes: generateChanges(state.nodes, nodes),
});
}
case "DESELECT_ALL_NODES": {
const nodes = Object.fromEntries(
Object.entries(state.nodes).map(([key, value]) => [key, { ...value, selected: false }])
);
return applyFilterToState({
...state,
nodes,
changes: generateChanges(state.nodes, nodes),
});
}
case "TOGGLE_NODE_SELECTION": {
const currentlySelected = state.nodes[action.payload.id]?.selected ?? false;
if (currentlySelected) {
return reducer(state, { type: "DESELECT_NODE", payload: { id: action.payload.id } });
}
return reducer(state, {
type: "SELECT_NODE",
payload: {
id: action.payload.id,
scrollToNode: action.payload.scrollToNode,
scrollToNodeFn: action.payload.scrollToNodeFn,
},
});
}
case "EXPAND_NODE": {
const newNodes = {
...state.nodes,
[action.payload.id]: { ...state.nodes[action.payload.id], expanded: true },
};
if (action.payload.scrollToNode) {
action.payload.scrollToNodeFn(action.payload.id);
}
const visibleNodes = applyVisibility(state.tree, newNodes);
return applyFilterToState({
...state,
nodes: visibleNodes,
changes: generateChanges(state.nodes, visibleNodes),
});
}
case "COLLAPSE_NODE": {
const visibleNodes = applyVisibility(state.tree, {
...state.nodes,
[action.payload.id]: { ...state.nodes[action.payload.id], expanded: false },
});
return applyFilterToState({
...state,
nodes: visibleNodes,
changes: generateChanges(state.nodes, visibleNodes),
});
}
case "TOGGLE_EXPAND_NODE": {
const currentlyExpanded = state.nodes[action.payload.id]?.expanded ?? true;
if (currentlyExpanded) {
return reducer(state, {
type: "COLLAPSE_NODE",
payload: { id: action.payload.id },
});
}
return reducer(state, {
type: "EXPAND_NODE",
payload: {
id: action.payload.id,
scrollToNode: action.payload.scrollToNode,
scrollToNodeFn: action.payload.scrollToNodeFn,
},
});
}
case "EXPAND_ALL_BELOW_DEPTH": {
const nodesToExpand = state.tree.filter(
(n) => n.level >= action.payload.depth && n.hasChildren
);
const newNodes = Object.fromEntries(
Object.entries(state.nodes).map(([key, value]) => [
key,
{
...value,
expanded: nodesToExpand.find((n) => n.id === key) ? true : value.expanded,
},
])
);
const visibleNodes = applyVisibility(state.tree, newNodes);
return applyFilterToState({
...state,
nodes: visibleNodes,
changes: generateChanges(state.nodes, visibleNodes),
});
}
case "COLLAPSE_ALL_BELOW_DEPTH": {
const nodesToCollapse = state.tree.filter(
(n) => n.level >= action.payload.depth && n.hasChildren
);
const newNodes = Object.fromEntries(
Object.entries(state.nodes).map(([key, value]) => [
key,
{
...value,
expanded: nodesToCollapse.find((n) => n.id === key) ? false : value.expanded,
},
])
);
const visibleNodes = applyVisibility(state.tree, newNodes);
return applyFilterToState({
...state,
nodes: visibleNodes,
changes: generateChanges(state.nodes, visibleNodes),
});
}
case "EXPAND_LEVEL": {
const nodesToExpand = state.tree.filter(
(n) => n.level <= action.payload.level && n.hasChildren
);
const newNodes = Object.fromEntries(
Object.entries(state.nodes).map(([key, value]) => [
key,
{
...value,
expanded: nodesToExpand.find((n) => n.id === key) ? true : value.expanded,
},
])
);
const visibleNodes = applyVisibility(state.tree, newNodes);
return applyFilterToState({
...state,
nodes: visibleNodes,
changes: generateChanges(state.nodes, visibleNodes),
});
}
case "COLLAPSE_LEVEL": {
const nodesToCollapse = state.tree.filter(
(n) => n.level === action.payload.level && n.hasChildren
);
const newNodes = Object.fromEntries(
Object.entries(state.nodes).map(([key, value]) => [
key,
{
...value,
expanded: nodesToCollapse.find((n) => n.id === key) ? false : value.expanded,
},
])
);
const visibleNodes = applyVisibility(state.tree, newNodes);
return applyFilterToState({
...state,
nodes: visibleNodes,
changes: generateChanges(state.nodes, visibleNodes),
});
}
case "TOGGLE_EXPAND_LEVEL": {
//first get the first item at that level in the tree. If it is expanded, collapse all nodes at that level
//if it is collapsed, expand all nodes at that level
const nodesAtLevel = state.tree.filter(
(n) => n.level === action.payload.level && n.hasChildren
);
const firstNode = nodesAtLevel[0];
if (!firstNode) {
return state;
}
const currentlyExpanded = state.nodes[firstNode.id]?.expanded ?? true;
const currentVisible = state.nodes[firstNode.id]?.visible ?? true;
if (currentlyExpanded && currentVisible) {
return reducer(state, {
type: "COLLAPSE_LEVEL",
payload: {
level: action.payload.level,
},
});
}
return reducer(state, {
type: "EXPAND_LEVEL",
payload: {
level: action.payload.level,
},
});
}
case "SELECT_FIRST_VISIBLE_NODE": {
const node = firstVisibleNode(state.tree, state.filteredNodes);
if (node) {
return reducer(state, {
type: "SELECT_NODE",
payload: {
id: node.id,
scrollToNode: action.payload.scrollToNode,
scrollToNodeFn: action.payload.scrollToNodeFn,
},
});
}
return state;
}
case "SELECT_LAST_VISIBLE_NODE": {
const node = lastVisibleNode(state.tree, state.filteredNodes);
if (node) {
return reducer(state, {
type: "SELECT_NODE",
payload: {
id: node.id,
scrollToNode: action.payload.scrollToNode,
scrollToNodeFn: action.payload.scrollToNodeFn,
},
});
}
return state;
}
case "SELECT_NEXT_VISIBLE_NODE": {
const selected = selectedIdFromState(state.nodes);
if (!selected) {
return reducer(state, {
type: "SELECT_FIRST_VISIBLE_NODE",
payload: {
scrollToNode: action.payload.scrollToNode,
scrollToNodeFn: action.payload.scrollToNodeFn,
},
});
}
const visible = visibleNodes(state.tree, state.filteredNodes);
const selectedIndex = visible.findIndex((node) => node.id === selected);
const nextNode = visible[selectedIndex + 1];
if (nextNode) {
return reducer(state, {
type: "SELECT_NODE",
payload: {
id: nextNode.id,
scrollToNode: action.payload.scrollToNode,
scrollToNodeFn: action.payload.scrollToNodeFn,
},
});
}
return state;
}
case "SELECT_PREVIOUS_VISIBLE_NODE": {
const selected = selectedIdFromState(state.nodes);
if (!selected) {
return reducer(state, {
type: "SELECT_FIRST_VISIBLE_NODE",
payload: {
scrollToNode: action.payload.scrollToNode,
scrollToNodeFn: action.payload.scrollToNodeFn,
},
});
}
const visible = visibleNodes(state.tree, state.filteredNodes);
const selectedIndex = visible.findIndex((node) => node.id === selected);
const previousNode = visible[Math.max(0, selectedIndex - 1)];
if (previousNode) {
return reducer(state, {
type: "SELECT_NODE",
payload: {
id: previousNode.id,
scrollToNode: action.payload.scrollToNode,
scrollToNodeFn: action.payload.scrollToNodeFn,
},
});
}
return state;
}
case "SELECT_PARENT_NODE": {
const selected = selectedIdFromState(state.nodes);
if (!selected) {
return reducer(state, {
type: "SELECT_FIRST_VISIBLE_NODE",
payload: {
scrollToNode: action.payload.scrollToNode,
scrollToNodeFn: action.payload.scrollToNodeFn,
},
});
}
const selectedNode = state.tree.find((node) => node.id === selected);
if (!selectedNode) {
return state;
}
const parentNode = state.tree.find((node) => node.id === selectedNode.parentId);
if (parentNode) {
return reducer(state, {
type: "SELECT_NODE",
payload: {
id: parentNode.id,
scrollToNode: action.payload.scrollToNode,
scrollToNodeFn: action.payload.scrollToNodeFn,
},
});
}
return state;
}
case "UPDATE_TREE": {
//update the tree but try and keep the selected and expanded states
const selectedId = selectedIdFromState(state.nodes);
const collapsedIds = collapsedIdsFromState(state.nodes);
const newState = concreteStateFromInput({
...state,
tree: action.payload.tree,
selectedId,
collapsedIds,
});
return newState;
}
case "UPDATE_FILTER": {
const newState = applyFilterToState({
...state,
filter: action.payload.filter,
});
return newState;
}
default: {
assertNever(action);
}
}
throw new Error(`Unhandled action type: ${(action as any).type}`);
}
@@ -0,0 +1,251 @@
import type { Filter, FlatTree } from "./TreeView";
import type { Changes, NodeState, NodesState, TreeState } from "./reducer";
type PartialNodeState = Record<string, Partial<NodeState>>;
const defaultSelected = false;
const defaultExpanded = true;
export function concreteStateFromInput({
tree,
filter,
selectedId,
collapsedIds,
}: {
tree: FlatTree<any>;
filter: Filter<any, any> | undefined;
selectedId: string | undefined;
collapsedIds: string[] | undefined;
}): TreeState {
const state: PartialNodeState = {};
collapsedIds?.forEach((id) => {
const hasTreeItem = tree.some((item) => item.id === id);
if (hasTreeItem) {
state[id] = { expanded: false };
}
});
if (selectedId) {
const selectedNode = tree.find((node) => node.id === selectedId);
if (selectedNode) {
state[selectedId] = { selected: true };
//make sure all parents are expanded
let parentId = selectedNode.parentId;
while (parentId) {
state[parentId] = { expanded: true };
const parent = tree.find((node) => node.id === parentId);
parentId = parent?.parentId;
}
}
}
const nodes = concreteStateFromPartialState(tree, state);
return applyFilterToState({
tree,
nodes,
changes: { selectedId },
filter,
filteredNodes: nodes,
visibleNodeIds: [],
});
}
export function concreteStateFromPartialState<TData>(
tree: FlatTree<TData>,
state: PartialNodeState
): NodesState {
const concreteState = tree.reduce(
(acc, node) => {
acc[node.id] = {
selected: acc[node.id]?.selected ?? defaultSelected,
expanded: acc[node.id]?.expanded ?? defaultExpanded,
visible: acc[node.id]?.visible ?? true,
};
return acc;
},
state as Record<string, NodeState>
);
return applyVisibility(tree, concreteState);
}
export function applyVisibility<TData>(tree: FlatTree<TData>, state: NodesState): NodesState {
const newState = tree.reduce((acc, node) => {
//groups are open by default
const nodeState = state[node.id] ?? {
selected: defaultSelected,
expanded: node.hasChildren ? defaultExpanded : !defaultExpanded,
};
const parent = node.parentId
? acc[node.parentId]
: { selected: defaultSelected, expanded: defaultExpanded, visible: true };
const visible = parent.expanded && parent.visible === true ? true : false;
acc[node.id] = { ...nodeState, visible };
return acc;
}, {} as NodesState);
return newState;
}
export function selectedIdFromState(state: NodesState): string | undefined {
const selected = Object.entries(state).find(([id, node]) => node.selected);
return selected?.[0];
}
export function applyFilterToState<_TData>({
tree,
nodes,
filter,
visibleNodeIds,
changes,
}: TreeState): TreeState {
if (!filter || !filter.value) {
return {
tree,
nodes,
filteredNodes: nodes,
changes,
filter,
visibleNodeIds: visibleNodes(tree, nodes).map((node) => node.id),
};
}
//we need to do two passes, first collect all the nodes that are results
const newFilteredOut = new Set<string>();
for (const node of tree) {
if (!filter.fn(filter.value, node)) {
newFilteredOut.add(node.id);
}
}
//nothing is filtered out
if (newFilteredOut.size === 0) {
return {
tree,
nodes,
filteredNodes: nodes,
changes,
filter,
visibleNodeIds: visibleNodes(tree, nodes).map((node) => node.id),
};
}
//copy of nodes
const filteredNodes = { ...nodes };
const selected = selectedIdFromState(filteredNodes);
const visible = new Set<string>();
const expanded = new Set<string>();
//figure out the state of each node
for (const node of tree) {
const shouldDisplay = !newFilteredOut.has(node.id);
//if the node is visible, make all the parents visible and expanded
if (shouldDisplay) {
//should be visible
visible.add(node.id);
//if it has children it should be expanded
if (node.hasChildren) {
expanded.add(node.id);
}
//parents need to be both visible and expanded
let parentId = node.parentId;
while (parentId) {
visible.add(parentId);
expanded.add(parentId);
parentId = tree.find((node) => node.id === parentId)?.parentId;
}
//children should be visible and if they have children expanded
if (node.hasChildren) {
const children = tree.filter((child) => child.parentId === node.id);
for (const child of children) {
visible.add(child.id);
if (child.hasChildren) {
expanded.add(child.id);
}
}
}
}
}
const allItems = new Set(tree.map((node) => node.id));
const hidden = difference(allItems, visible);
const collapsed = difference(visible, expanded);
//now set the visibility and expanded state
for (const id of hidden) {
filteredNodes[id] = { ...filteredNodes[id], visible: false };
}
for (const id of visible) {
filteredNodes[id] = { ...filteredNodes[id], visible: true };
}
for (const id of collapsed) {
filteredNodes[id] = { ...filteredNodes[id], expanded: false };
}
for (const id of expanded) {
filteredNodes[id] = { ...filteredNodes[id], expanded: true };
}
if (selected) {
if (visible.has(selected)) {
filteredNodes[selected] = { ...filteredNodes[selected], selected: true };
} else {
filteredNodes[selected] = { ...filteredNodes[selected], selected: false };
}
}
return {
tree,
nodes,
filteredNodes,
changes,
filter,
visibleNodeIds: visibleNodes(tree, filteredNodes).map((node) => node.id),
};
}
export function visibleNodes(tree: FlatTree<any>, nodes: NodesState) {
return tree.filter((node) => nodes[node.id].visible === true);
}
export function firstVisibleNode(tree: FlatTree<any>, nodes: NodesState) {
return tree.find((node) => nodes[node.id].visible === true);
}
export function lastVisibleNode(tree: FlatTree<any>, nodes: NodesState) {
return tree
.slice()
.reverse()
.find((node) => nodes[node.id].visible === true);
}
function difference<T>(a: Set<T>, b: Set<T>): Set<T> {
return new Set([...a].filter((x) => !b.has(x)));
}
export function collapsedIdsFromState(state: NodesState): string[] {
return Object.entries(state)
.filter(([_, s]) => s.expanded === false)
.map(([id]) => id);
}
export function generateChanges(a: NodesState, b: NodesState): Changes {
//if selected === defaultSelected, remove it
//if expanded === defaultExpanded, remove it
//if both are default, remove the node
const selectedIdA = selectedIdFromState(a);
const selectedIdB = selectedIdFromState(b);
const collapsedIdsA = new Set(collapsedIdsFromState(a));
const collapsedIdsB = new Set(collapsedIdsFromState(b));
const _collapsedChanges = [...difference(collapsedIdsA, collapsedIdsB)];
return {
selectedId: selectedIdA !== selectedIdB ? selectedIdB : undefined,
};
}
@@ -0,0 +1,26 @@
import { cn } from "~/utils/cn";
import { CopyableText } from "./CopyableText";
import { SimpleTooltip } from "./Tooltip";
export function TruncatedCopyableValue({
value,
className,
length = 8,
}: {
value: string;
className?: string;
length?: number;
}) {
return (
<SimpleTooltip
content={value}
button={
<span className={cn("flex h-6 items-center gap-1", className)}>
<CopyableText value={value.slice(-length)} copyValue={value} className="font-mono" />
</span>
}
asChild
disableHoverableContent
/>
);
}
@@ -0,0 +1,126 @@
import { cn } from "~/utils/cn";
import { type ParagraphVariant } from "./Paragraph";
const listVariants: Record<ParagraphVariant, { text: string; spacing: string; items: string }> = {
base: {
text: "font-sans text-base font-normal text-text-dimmed",
spacing: "mb-3",
items: "space-y-1 [&>li]:gap-1.5",
},
"base/bright": {
text: "font-sans text-base font-normal text-text-bright",
spacing: "mb-3",
items: "space-y-1 [&>li]:gap-1.5",
},
small: {
text: "font-sans text-sm font-normal text-text-dimmed",
spacing: "mb-2",
items: "space-y-0.5 [&>li]:gap-1",
},
"small/bright": {
text: "font-sans text-sm font-normal text-text-bright",
spacing: "mb-2",
items: "space-y-0.5 [&>li]:gap-1",
},
"small/dimmed": {
text: "font-sans text-sm font-normal text-text-dimmed",
spacing: "mb-2",
items: "space-y-0.5 [&>li]:gap-1",
},
"extra-small": {
text: "font-sans text-xs font-normal text-text-dimmed",
spacing: "mb-1.5",
items: "space-y-0.5 [&>li]:gap-1",
},
"extra-small/bright": {
text: "font-sans text-xs font-normal text-text-bright",
spacing: "mb-1.5",
items: "space-y-0.5 [&>li]:gap-1",
},
"extra-small/dimmed": {
text: "font-sans text-xs font-normal text-text-dimmed",
spacing: "mb-1.5",
items: "space-y-0.5 [&>li]:gap-1",
},
"extra-small/dimmed/mono": {
text: "font-mono text-xs font-normal text-text-dimmed",
spacing: "mb-1.5",
items: "space-y-0.5 [&>li]:gap-1",
},
"extra-small/mono": {
text: "font-mono text-xs font-normal text-text-dimmed",
spacing: "mb-1.5",
items: "space-y-0.5 [&>li]:gap-1",
},
"extra-small/bright/mono": {
text: "font-mono text-xs text-text-bright",
spacing: "mb-1.5",
items: "space-y-0.5 [&>li]:gap-1",
},
"extra-small/caps": {
text: "font-sans text-xs uppercase tracking-wider font-normal text-text-dimmed",
spacing: "mb-1.5",
items: "space-y-0.5 [&>li]:gap-1",
},
"extra-small/bright/caps": {
text: "font-sans text-xs uppercase tracking-wider font-normal text-text-bright",
spacing: "mb-1.5",
items: "space-y-0.5 [&>li]:gap-1",
},
"extra-extra-small": {
text: "font-sans text-xxs font-normal text-text-dimmed",
spacing: "mb-1",
items: "space-y-0.5 [&>li]:gap-0.5",
},
"extra-extra-small/bright": {
text: "font-sans text-xxs font-normal text-text-bright",
spacing: "mb-1",
items: "space-y-0.5 [&>li]:gap-0.5",
},
"extra-extra-small/caps": {
text: "font-sans text-xxs uppercase tracking-wider font-normal text-text-dimmed",
spacing: "mb-1",
items: "space-y-0.5 [&>li]:gap-0.5",
},
"extra-extra-small/bright/caps": {
text: "font-sans text-xxs uppercase tracking-wider font-normal text-text-bright",
spacing: "mb-1",
items: "space-y-0.5 [&>li]:gap-0.5",
},
"extra-extra-small/dimmed/caps": {
text: "font-sans text-xxs uppercase tracking-wider font-normal text-text-dimmed",
spacing: "mb-1",
items: "space-y-0.5 [&>li]:gap-0.5",
},
};
type UnorderedListProps = {
variant?: ParagraphVariant;
className?: string;
spacing?: boolean;
children: React.ReactNode;
} & React.HTMLAttributes<HTMLUListElement>;
export function UnorderedList({
variant = "base",
className,
spacing = false,
children,
...props
}: UnorderedListProps) {
const v = listVariants[variant];
return (
<ul
className={cn(
"list-none [&>li]:flex [&>li]:items-baseline [&>li]:before:shrink-0 [&>li]:before:content-['•']",
v.text,
v.items,
spacing && v.spacing,
className
)}
{...props}
>
{children}
</ul>
);
}
@@ -0,0 +1,128 @@
import {
Bar,
BarChart,
ReferenceLine,
ResponsiveContainer,
Tooltip,
YAxis,
type TooltipProps,
} from "recharts";
import { cn } from "~/utils/cn";
import { formatDateTime } from "./DateTime";
import { Header3 } from "./Headers";
import TooltipPortal from "./TooltipPortal";
type UsageDatum = { date: Date; count: number };
type UnitLabel = { singular: string; plural: string };
export type UsageSparklineProps = {
/** Equal-width time buckets, oldest first. */
data?: number[];
/** Epoch ms of the first bucket's start. When omitted, the last bucket is anchored to now. */
bucketStartMs?: number;
/** Width of each bucket in ms. Defaults to one hour. */
bucketIntervalMs?: number;
/** Bar colour. Defaults to blue. */
color?: string;
/** Unit shown in the tooltip (e.g. calls, tokens). */
unitLabel?: UnitLabel;
/** Format the trailing total. Defaults to `toLocaleString`. */
formatTotal?: (total: number) => string;
/** Class for the trailing total label. */
totalClassName?: string;
};
/**
* Inline 24h sparkline for list rows. Renders a small bar chart plus a trailing
* total, or an em-dash when there's no data. Shared by the prompts and models
* lists — keep it presentational (the caller supplies the zero-filled buckets).
*/
export function UsageSparkline({
data,
bucketStartMs,
bucketIntervalMs,
color = "var(--color-pending)",
unitLabel = { singular: "call", plural: "calls" },
formatTotal,
totalClassName = "text-blue-400",
}: UsageSparklineProps) {
if (!data || data.every((v) => v === 0)) {
return <span className="text-text-dimmed"></span>;
}
const total = data.reduce((a, b) => a + b, 0);
const max = Math.max(...data);
// Map each bucket to a dated point so the tooltip can show the window it
// represents. Buckets are `intervalMs` wide; if the caller didn't pass the
// first bucket's start, anchor the last bucket to now (hourly default).
const intervalMs = bucketIntervalMs ?? 3600_000;
const startMs = bucketStartMs ?? Date.now() - (data.length - 1) * intervalMs;
const chartData: UsageDatum[] = data.map((count, i) => ({
date: new Date(startMs + i * intervalMs),
count,
}));
return (
<div className="flex items-start gap-2">
<div className="h-6 w-28 rounded-sm">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={chartData} margin={{ top: 0, right: 0, left: 0, bottom: 0 }}>
<YAxis domain={[0, max || 1]} hide />
<Tooltip
cursor={{ fill: "rgba(255, 255, 255, 0.06)" }}
content={<UsageSparklineTooltip unitLabel={unitLabel} />}
allowEscapeViewBox={{ x: true, y: true }}
wrapperStyle={{ zIndex: 1000 }}
animationDuration={0}
/>
<Bar
dataKey="count"
fill={color}
strokeWidth={0}
isAnimationActive={false}
minPointSize={1}
/>
<ReferenceLine y={0} stroke="var(--color-border-bright)" strokeWidth={1} />
{max > 0 && (
<ReferenceLine
y={max}
stroke="var(--color-border-brighter)"
strokeDasharray="4 4"
strokeWidth={1}
/>
)}
</BarChart>
</ResponsiveContainer>
</div>
<span className={cn("-mt-1 text-xs tabular-nums", totalClassName)}>
{formatTotal ? formatTotal(total) : total.toLocaleString()}
</span>
</div>
);
}
function UsageSparklineTooltip({
active,
payload,
unitLabel,
}: TooltipProps<number, string> & { unitLabel: UnitLabel }) {
if (!active || !payload || payload.length === 0) return null;
const entry = payload[0].payload as UsageDatum;
const date = entry.date instanceof Date ? entry.date : new Date(entry.date);
const formattedDate = formatDateTime(date, "UTC", [], false, true);
return (
<TooltipPortal active={active}>
<div className="rounded-sm border border-grid-bright bg-background-dimmed px-3 py-2">
<Header3 className="border-b border-b-border-bright pb-2">{formattedDate}</Header3>
<div className="mt-2 text-xs text-text-bright">
<span className="tabular-nums">{entry.count.toLocaleString()}</span>{" "}
<span className="text-text-dimmed">
{entry.count === 1 ? unitLabel.singular : unitLabel.plural}
</span>
</div>
</div>
</TooltipPortal>
);
}
@@ -0,0 +1,191 @@
import type { ColumnFormatType, OutputColumnMetadata } from "@internal/tsql";
import { Hash } from "lucide-react";
import { useMemo } from "react";
import type {
BigNumberAggregationType,
BigNumberConfiguration,
} from "~/components/metrics/QueryWidget";
import { createValueFormatter } from "~/utils/columnFormat";
import { AnimatedNumber } from "../AnimatedNumber";
import { ChartBlankState } from "./ChartBlankState";
import { Spinner } from "../Spinner";
interface BigNumberCardProps {
rows: Record<string, unknown>[];
columns: OutputColumnMetadata[];
config: BigNumberConfiguration;
isLoading?: boolean;
}
/**
* Extracts numeric values from a specific column across all rows,
* optionally sorting them first.
*/
function extractColumnValues(
rows: Record<string, unknown>[],
column: string,
sortDirection?: "asc" | "desc"
): number[] {
const values: number[] = [];
const sortedRows = sortDirection
? [...rows].sort((a, b) => {
const aVal = toNumber(a[column]);
const bVal = toNumber(b[column]);
return sortDirection === "asc" ? aVal - bVal : bVal - aVal;
})
: rows;
for (const row of sortedRows) {
const val = row[column];
if (typeof val === "number") {
values.push(val);
} else if (typeof val === "string") {
const parsed = parseFloat(val);
if (!isNaN(parsed)) {
values.push(parsed);
}
}
}
return values;
}
function toNumber(value: unknown): number {
if (typeof value === "number") return value;
if (typeof value === "string") {
const parsed = parseFloat(value);
return isNaN(parsed) ? 0 : parsed;
}
return 0;
}
/**
* Aggregate an array of numbers using the specified aggregation function
*/
function aggregateValues(values: number[], aggregation: BigNumberAggregationType): number {
if (values.length === 0) return 0;
switch (aggregation) {
case "sum":
return values.reduce((a, b) => a + b, 0);
case "avg":
return values.reduce((a, b) => a + b, 0) / values.length;
case "count":
return values.length;
case "min":
return Math.min(...values);
case "max":
return Math.max(...values);
case "first":
return values[0];
case "last":
return values[values.length - 1];
}
}
/**
* Computes the display value and unit suffix for abbreviated display.
* Returns the divided-down number (e.g. 1.5 for 1500) and the suffix (e.g. "K"),
* along with the appropriate decimal places for formatting.
*/
function abbreviateValue(value: number): {
displayValue: number;
unitSuffix?: string;
decimalPlaces: number;
} {
if (Math.abs(value) >= 1_000_000_000) {
const v = value / 1_000_000_000;
return { displayValue: v, unitSuffix: "B", decimalPlaces: v % 1 === 0 ? 0 : 1 };
}
if (Math.abs(value) >= 1_000_000) {
const v = value / 1_000_000;
return { displayValue: v, unitSuffix: "M", decimalPlaces: v % 1 === 0 ? 0 : 1 };
}
if (Math.abs(value) >= 1_000) {
const v = value / 1_000;
return { displayValue: v, unitSuffix: "K", decimalPlaces: v % 1 === 0 ? 0 : 1 };
}
return { displayValue: value, decimalPlaces: getDecimalPlaces(value) };
}
/**
* Determines decimal places for plain (non-abbreviated) display.
*/
function getDecimalPlaces(value: number): number {
if (Number.isInteger(value)) return 0;
const abs = Math.abs(value);
if (abs >= 100) return 0;
if (abs >= 10) return 1;
if (abs >= 1) return 2;
if (abs >= 0.01) return 3;
return 4;
}
export function BigNumberCard({ rows, columns, config, isLoading = false }: BigNumberCardProps) {
const { column, aggregation, sortDirection, abbreviate = true, prefix, suffix } = config;
const result = useMemo(() => {
if (rows.length === 0) return null;
const values = extractColumnValues(rows, column, sortDirection);
if (values.length === 0) return null;
return aggregateValues(values, aggregation);
}, [rows, column, aggregation, sortDirection]);
// Look up column format for format-aware display
const columnValueFormatter = useMemo(() => {
const columnMeta = columns.find((c) => c.name === column);
const formatType = (columnMeta?.format ?? columnMeta?.customRenderType) as
| ColumnFormatType
| undefined;
return createValueFormatter(formatType);
}, [columns, column]);
if (isLoading) {
return (
<div className="grid h-full place-items-center @container-size">
<Spinner className="size-6" />
</div>
);
}
if (result === null) {
return <ChartBlankState icon={Hash} message="No data to display" />;
}
// Use format-aware formatter when available
if (columnValueFormatter) {
return (
<div className="h-full w-full @container-size">
<div className="grid h-full w-full place-items-center">
<div className="flex items-baseline gap-[0.15em] whitespace-nowrap text-[clamp(24px,12cqw,96px)] font-normal tabular-nums leading-none text-text-bright">
{prefix && <span>{prefix}</span>}
<span>{columnValueFormatter(result)}</span>
{suffix && <span className="text-[0.4em] text-text-dimmed">{suffix}</span>}
</div>
</div>
</div>
);
}
const { displayValue, unitSuffix, decimalPlaces } = abbreviate
? abbreviateValue(result)
: { displayValue: result, unitSuffix: undefined, decimalPlaces: getDecimalPlaces(result) };
return (
<div className="h-full w-full @container-size">
<div className="grid h-full w-full place-items-center">
<div className="flex items-baseline gap-[0.15em] whitespace-nowrap text-[clamp(24px,12cqw,96px)] font-normal tabular-nums leading-none text-text-bright">
{prefix && <span>{prefix}</span>}
<AnimatedNumber value={displayValue} decimalPlaces={decimalPlaces} />
{(unitSuffix || suffix) && (
<span className="text-[0.4em] text-text-dimmed">
{unitSuffix}
{unitSuffix && suffix ? " " : ""}
{suffix}
</span>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,41 @@
import { type ReactNode } from "react";
import { cn } from "~/utils/cn";
import { Header3 } from "../Headers";
export const Card = ({ children, className }: { children: ReactNode; className?: string }) => {
return (
<div
className={cn(
"flex flex-col rounded-lg border border-grid-bright bg-background-bright pb-1.5 pt-3",
className
)}
>
{children}
</div>
);
};
const CardHeader = ({ children, draggable }: { children: ReactNode; draggable?: boolean }) => {
return (
<Header3
className={cn(
"drag-handle mb-3 flex items-center justify-between gap-2 pl-4 pr-3",
draggable && "cursor-grab active:cursor-grabbing"
)}
>
{children}
</Header3>
);
};
const CardContent = ({ children, className }: { children: ReactNode; className?: string }) => {
return <div className={cn("px-2", className)}>{children}</div>;
};
const CardAccessory = ({ children }: { children: ReactNode }) => {
return <div className="flex items-center gap-2">{children}</div>;
};
Card.Header = CardHeader;
Card.Content = CardContent;
Card.Accessory = CardAccessory;
@@ -0,0 +1,464 @@
import * as React from "react";
import * as RechartsPrimitive from "recharts";
import { cn } from "~/utils/cn";
import { AnimatedNumber } from "../AnimatedNumber";
import TooltipPortal from "../TooltipPortal";
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: '[data-theme="dark"]' } as const;
export type ChartState = "loading" | "noData" | "invalid" | "loaded" | undefined;
export type ChartConfig = {
[k in string]: {
label?: React.ReactNode;
icon?: React.ComponentType;
value?: number;
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
);
};
type ChartContextProps = {
config: ChartConfig;
};
const ChartContext = React.createContext<ChartContextProps | null>(null);
function useChart() {
const context = React.useContext(ChartContext);
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />");
}
return context;
}
const ChartContainer = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
config: ChartConfig;
children: React.ComponentProps<typeof RechartsPrimitive.ResponsiveContainer>["children"];
}
>(({ id, className, children, config, ...props }, ref) => {
const uniqueId = React.useId();
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`;
return (
<ChartContext.Provider value={{ config }}>
<div
data-chart={chartId}
ref={ref}
className={cn(
"[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-hidden [&_.recharts-surface]:outline-hidden",
className
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>{children}</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
);
});
ChartContainer.displayName = "Chart";
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(([, config]) => config.theme || config.color);
if (!colorConfig.length) {
return null;
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color = itemConfig.theme?.[theme as keyof typeof itemConfig.theme] || itemConfig.color;
return color ? ` --color-${key}: ${color};` : null;
})
.join("\n")}
}
`
)
.join("\n"),
}}
/>
);
};
const ChartTooltip = RechartsPrimitive.Tooltip;
const ChartTooltipContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & {
hideLabel?: boolean;
hideIndicator?: boolean;
indicator?: "line" | "dot" | "dashed";
nameKey?: string;
labelKey?: string;
/** Optional formatter for numeric values (e.g. bytes, duration) */
valueFormatter?: (value: number) => string;
}
>(
(
{
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
valueFormatter,
},
ref
) => {
const { config } = useChart();
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null;
}
const [item] = payload;
const key = `${labelKey || item?.dataKey || item?.name || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const value =
!labelKey && typeof label === "string"
? config[label as keyof typeof config]?.label || label
: itemConfig?.label;
if (labelFormatter) {
return (
<div className={cn("font-medium", labelClassName)}>{labelFormatter(value, payload)}</div>
);
}
if (!value) {
return null;
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>;
}, [label, labelFormatter, payload, hideLabel, labelClassName, config, labelKey]);
if (!active || !payload?.length) {
return null;
}
const nestLabel = payload.length === 1 && indicator !== "dot";
return (
<TooltipPortal active={active}>
<div
ref={ref}
className={cn(
"grid min-w-32 items-start gap-1.5 rounded-lg border border-grid-bright bg-background-bright px-2.5 py-1.5 text-xs shadow-xl",
className
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const indicatorColor = color || item.payload.fill || item.color;
return (
<div
key={item.dataKey}
className={cn(
"[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5",
indicator === "dot" && "items-center"
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
{
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
}
)}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center"
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label || item.name}
</span>
</div>
{item.value != null && (
<span className="text-foreground font-mono font-medium tabular-nums">
{valueFormatter && typeof item.value === "number"
? valueFormatter(item.value)
: item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>
);
})}
</div>
</div>
</TooltipPortal>
);
}
);
ChartTooltipContent.displayName = "ChartTooltip";
const ChartLegend = RechartsPrimitive.Legend;
type ExtendedLegendPayload = Parameters<
NonNullable<RechartsPrimitive.LegendProps["formatter"]>
>[0] & {
payload?: { remainingCount?: number };
};
const ChartLegendContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> &
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
hideIcon?: boolean;
nameKey?: string;
}
>(({ className, hideIcon = false, payload, verticalAlign = "bottom", nameKey }, ref) => {
const { config } = useChart();
if (!payload?.length) {
return null;
}
return (
<div
ref={ref}
className={cn(
"flex items-center justify-center gap-4",
verticalAlign === "top" ? "pb-3" : "pt-3",
className
)}
>
{payload.map((item) => {
const key = `${nameKey || item.dataKey || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
return (
<div
key={item.value}
className={cn(
"[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3"
)}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
);
})}
</div>
);
});
ChartLegendContent.displayName = "ChartLegend";
const ChartLegendContentRows = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> &
Omit<Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign">, "payload"> & {
payload?: ExtendedLegendPayload[];
hideIcon?: boolean;
nameKey?: string;
onMouseEnter?: (e: any) => void;
onMouseLeave?: (e: any) => void;
data?: Record<string, number>;
activeKey?: string | null;
renderViewMore?: (remainingCount: number) => React.ReactNode;
}
>(
(
{
className,
hideIcon = false,
payload,
verticalAlign = "bottom",
nameKey,
onMouseEnter,
onMouseLeave,
data,
activeKey,
renderViewMore,
},
ref
) => {
const { config } = useChart();
if (!payload?.length) {
return null;
}
return (
<div
ref={ref}
className={cn(
"flex flex-col items-start justify-between",
verticalAlign === "top" ? "pb-4" : "pt-4",
className
)}
>
{payload.map((item) => {
const key = `${nameKey || item.dataKey || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
// Handle view more button
if (item.dataKey === "view-more" && renderViewMore && item.payload?.remainingCount) {
return renderViewMore(item.payload.remainingCount);
}
const total = item.dataKey && data ? data[item.dataKey as string] : undefined;
return (
<div
key={key}
className={cn(
"relative flex w-full items-center justify-between gap-2 rounded px-2 py-1 transition",
total === 0 && "opacity-50"
)}
onMouseEnter={() => onMouseEnter?.(item)}
onMouseLeave={() => onMouseLeave?.(item)}
>
{activeKey === item.dataKey && item.color && (
<div
className="absolute inset-0 rounded opacity-10"
style={{ backgroundColor: item.color }}
/>
)}
<div className="relative flex w-full items-center justify-between gap-2">
<div
className={cn(
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-text-dimmed"
)}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
item.color && (
<div
className="h-3 w-1 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)
)}
{itemConfig?.label && (
<span
className={
activeKey === item.dataKey ? "text-text-bright" : "text-text-dimmed"
}
>
{itemConfig.label}
</span>
)}
</div>
{total !== undefined && (
<span
className={cn(
"tabular-nums",
activeKey === item.dataKey ? "text-text-bright" : "text-text-dimmed"
)}
>
<AnimatedNumber value={total} duration={0.25} />
</span>
)}
</div>
</div>
);
})}
</div>
);
}
);
ChartLegendContentRows.displayName = "ChartLegendRows";
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(config: ChartConfig, payload: unknown, key: string) {
if (typeof payload !== "object" || payload === null) {
return undefined;
}
const payloadPayload =
"payload" in payload && typeof payload.payload === "object" && payload.payload !== null
? payload.payload
: undefined;
let configLabelKey: string = key;
if (key in payload && typeof payload[key as keyof typeof payload] === "string") {
configLabelKey = payload[key as keyof typeof payload] as string;
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[key as keyof typeof payloadPayload] as string;
}
return configLabelKey in config ? config[configLabelKey] : config[key as keyof typeof config];
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartLegendContentRows,
ChartStyle,
};
@@ -0,0 +1,390 @@
import React, { useCallback } from "react";
import {
Bar,
BarChart,
CartesianGrid,
ReferenceArea,
ReferenceLine,
XAxis,
YAxis,
type XAxisProps,
type YAxisProps,
} from "recharts";
import { ChartTooltip, ChartTooltipContent } from "~/components/primitives/charts/Chart";
import TooltipPortal from "~/components/primitives/TooltipPortal";
import { useChartContext } from "./ChartContext";
import { ChartBarInvalid, ChartBarLoading, ChartBarNoData } from "./ChartLoading";
import { useHasNoData } from "./ChartRoot";
import { defaultYAxisTickFormatter, useYAxisWidth } from "./useYAxisWidth";
import { useXAxisTicks } from "./useXAxisTicks";
import { useChartSync } from "./ChartSyncContext";
import { ZoomTooltip, useZoomHandlers } from "./ChartZoom";
// Dashed line mirroring the hovered x across synced charts.
const SYNC_LINE_COLOR = "var(--color-text-faint)";
// Shared with ChartLine so bar/line align when toggling. Right margin keeps the
// centered last x-axis label from clipping; bottom gives angled labels room.
export const CHART_MARGIN = { top: 5, right: 20, bottom: 5, left: 5 } as const;
/** While drag-to-zooming, show the selected From/To range instead of hovered values. */
function ZoomRangeTooltip({ active, from, to }: { active?: boolean; from: string; to: string }) {
if (!active) return null;
return (
<TooltipPortal active={active}>
<div className="grid grid-cols-[auto_auto] gap-x-2 gap-y-1 rounded-lg border border-grid-bright bg-background-bright px-2.5 py-1.5 text-xs shadow-xl">
<span className="text-right text-text-dimmed">From:</span>
<span className="tabular-nums text-text-bright">{from}</span>
<span className="text-right text-text-dimmed">To:</span>
<span className="tabular-nums text-text-bright">{to}</span>
</div>
</TooltipPortal>
);
}
//TODO: fix the first and last bars in a stack not having rounded corners
type ReferenceLineProps = {
value: number;
label: string;
};
// ============================================================================
// COMPOUND COMPONENT API
// ============================================================================
export type ChartBarRendererProps = {
/** Stack ID for stacked bar charts */
stackId?: string;
/** Custom X-axis props to merge with defaults */
xAxisProps?: Partial<XAxisProps>;
/** Custom Y-axis props to merge with defaults */
yAxisProps?: Partial<YAxisProps>;
/** Reference line (horizontal) */
referenceLine?: ReferenceLineProps;
/** Custom tooltip label formatter */
tooltipLabelFormatter?: (label: string, payload: any[]) => string;
/** Optional formatter for numeric tooltip values (e.g. bytes, duration) */
tooltipValueFormatter?: (value: number) => string;
/** Corner radius for the outermost bars in each stack (defaults to 2). Pass 0 for square corners. */
barRadius?: number;
/** Width injected by ResponsiveContainer */
width?: number;
/** Height injected by ResponsiveContainer */
height?: number;
};
/**
* Bar chart renderer for the compound component system.
* Must be used within a Chart.Root.
*
* @example
* ```tsx
* <Chart.Root config={config} data={data} dataKey="day">
* <Chart.Bar stackId="a" />
* <Chart.Legend />
* </Chart.Root>
* ```
*/
export function ChartBarRenderer({
stackId,
xAxisProps: xAxisPropsProp,
yAxisProps: yAxisPropsProp,
referenceLine,
tooltipLabelFormatter,
tooltipValueFormatter,
barRadius = 2,
width,
height,
}: ChartBarRendererProps) {
const {
config,
data,
dataKey,
dataKeys: _dataKeys,
visibleSeries,
state,
highlight,
setActivePayload,
zoom,
showLegend,
} = useChartContext();
const hasNoData = useHasNoData();
const zoomHandlers = useZoomHandlers();
const sync = useChartSync();
const enableZoom = zoom !== null;
const yAxisTickFormatter = yAxisPropsProp?.tickFormatter ?? defaultYAxisTickFormatter;
const computedYAxisWidth = useYAxisWidth(data, visibleSeries, yAxisTickFormatter);
// Width-aware horizontal labels, but only when the caller isn't already
// controlling ticks/interval/angle (e.g. the query widget's angled axes).
const callerControlsXTicks =
xAxisPropsProp?.ticks !== undefined ||
xAxisPropsProp?.interval !== undefined ||
xAxisPropsProp?.angle !== undefined;
// Plot width = full width minus the y-axis and horizontal margins.
const xAxisPlotWidth =
width != null
? Math.max(0, width - computedYAxisWidth - CHART_MARGIN.left - CHART_MARGIN.right)
: undefined;
const autoXTicks = useXAxisTicks(
data,
dataKey,
xAxisPlotWidth,
xAxisPropsProp?.tickFormatter as ((value: any, index: number) => string) | undefined
);
const handleBarClick = useCallback(
(barData: any, e: React.MouseEvent) => {
if (!enableZoom || !zoom) return;
e.stopPropagation();
if (!zoom.isSelecting) {
zoom.toggleInspectionLine(barData[dataKey]);
}
},
[enableZoom, zoom, dataKey]
);
// Reset highlight and cancel any in-progress zoom drag on leave.
const handleMouseLeave = useCallback(() => {
zoomHandlers.onMouseLeave?.();
highlight.reset();
sync?.setActiveX(null);
sync?.cancelZoom();
}, [zoomHandlers, highlight, sync]);
// Render loading/error states
if (state === "loading") {
return <ChartBarLoading />;
} else if (state === "noData" || hasNoData) {
return <ChartBarNoData />;
} else if (state === "invalid") {
return <ChartBarInvalid />;
}
// When zoom is enabled, collapse to first/last ticks during hover to make room
// for the zoom instructions.
const zoomXAxisTicks =
enableZoom && highlight.tooltipActive && data.length > 2
? [data[0]?.[dataKey], data[data.length - 1]?.[dataKey]]
: undefined;
// Zoom ticks win; otherwise use width-aware auto ticks unless the caller
// controls tick placement.
const useAutoXTicks = !callerControlsXTicks && !zoomXAxisTicks && autoXTicks != null;
const baseXTicks = zoomXAxisTicks ?? (useAutoXTicks ? autoXTicks : undefined);
const baseXInterval = useAutoXTicks ? 0 : ("preserveStartEnd" as const);
const syncActiveX = sync?.activeX ?? null;
const syncZoomSelection = sync?.zoomSelection ?? null;
// Bucket width so the committed zoom range includes the last selected bucket.
const bucketWidthMs = data.length >= 2 ? Number(data[1][dataKey]) - Number(data[0][dataKey]) : 0;
// Reuse the tooltip label formatter for the From/To edges (it reads `bucket` off the payload).
const formatZoomEdge = (v: number): string =>
tooltipLabelFormatter ? tooltipLabelFormatter("", [{ payload: { bucket: v } }]) : String(v);
let zoomFrom: string | null = null;
let zoomTo: string | null = null;
if (syncZoomSelection) {
const a = Number(syncZoomSelection.start);
const b = Number(syncZoomSelection.current);
if (Number.isFinite(a) && Number.isFinite(b)) {
zoomFrom = formatZoomEdge(Math.min(a, b));
zoomTo = formatZoomEdge(Math.max(a, b));
}
}
return (
<BarChart
data={data}
width={width}
height={height}
barCategoryGap={1}
margin={CHART_MARGIN}
className={sync?.zoomEnabled ? "cursor-crosshair select-none" : undefined}
onMouseDown={(e: any) => {
zoomHandlers.onMouseDown?.(e);
if (sync?.zoomEnabled && e?.activeLabel != null) sync.startZoom(e.activeLabel);
}}
onMouseMove={(e: any) => {
zoomHandlers.onMouseMove?.(e);
if (sync?.zoomEnabled && sync.zoomSelection && e?.activeLabel != null) {
sync.updateZoom(e.activeLabel);
}
if (e?.activePayload?.length) {
setActivePayload(e.activePayload, e.activeTooltipIndex);
highlight.setTooltipActive(true);
sync?.setActiveX(e.activeLabel ?? null);
} else {
highlight.setTooltipActive(false);
sync?.setActiveX(null);
}
}}
onMouseUp={() => {
zoomHandlers.onMouseUp?.();
if (sync?.zoomEnabled) sync.endZoom(bucketWidthMs);
}}
onClick={zoomHandlers.onClick}
onMouseLeave={handleMouseLeave}
>
<CartesianGrid vertical={false} stroke="var(--color-grid-bright)" />
<XAxis
dataKey={dataKey}
tickLine={false}
tickMargin={10}
axisLine={false}
ticks={baseXTicks}
interval={baseXInterval}
tick={{
fill: "var(--color-text-dimmed)",
fontSize: 11,
style: { fontVariantNumeric: "tabular-nums" },
}}
{...xAxisPropsProp}
/>
<YAxis
axisLine={false}
tickLine={false}
tickMargin={8}
width={computedYAxisWidth}
tick={{
fill: "var(--color-text-dimmed)",
fontSize: 11,
style: { fontVariantNumeric: "tabular-nums" },
}}
tickFormatter={yAxisTickFormatter}
domain={["auto", (dataMax: number) => dataMax * 1.15]}
{...yAxisPropsProp}
/>
{/* When legend is shown below the chart, render tooltip with cursor only (no content popup).
Otherwise render the full tooltip with zoom instructions. */}
<ChartTooltip
cursor={{ fill: "rgba(255, 255, 255, 0.06)" }}
content={
syncZoomSelection && zoomFrom != null && zoomTo != null ? (
<ZoomRangeTooltip from={zoomFrom} to={zoomTo} />
) : showLegend ? (
() => null
) : tooltipLabelFormatter ? (
<ChartTooltipContent valueFormatter={tooltipValueFormatter} />
) : (
<ZoomTooltip
isSelecting={zoom?.isSelecting}
refAreaLeft={zoom?.refAreaLeft}
refAreaRight={zoom?.refAreaRight}
invalidSelection={zoom?.invalidSelection}
/>
)
}
labelFormatter={tooltipLabelFormatter}
allowEscapeViewBox={{ x: false, y: true }}
animationDuration={0}
/>
{/* Zoom selection area - rendered before bars to appear behind them */}
{enableZoom && zoom?.refAreaLeft !== null && zoom?.refAreaRight !== null && (
<ReferenceArea
x1={zoom.refAreaLeft}
x2={zoom.refAreaRight}
strokeOpacity={0.4}
fill="var(--color-pending)"
fillOpacity={0.3}
/>
)}
{visibleSeries.map((key, index, array) => {
const dimmed =
!zoom?.isSelecting && highlight.activeBarKey !== null && highlight.activeBarKey !== key;
return (
<Bar
key={key}
dataKey={key}
stackId={stackId}
fill={config[key]?.color}
radius={
[
index === array.length - 1 ? barRadius : 0,
index === array.length - 1 ? barRadius : 0,
index === 0 ? barRadius : 0,
index === 0 ? barRadius : 0,
] as [number, number, number, number]
}
activeBar={false}
fillOpacity={dimmed ? 0.2 : 1}
onClick={(data, index, e) => handleBarClick(data, e)}
onMouseEnter={(entry, index) => {
if (entry.tooltipPayload?.[0]) {
const { dataKey: hoveredKey } = entry.tooltipPayload[0];
highlight.setHoveredBar(hoveredKey, index);
}
}}
onMouseLeave={highlight.reset}
isAnimationActive={false}
/>
);
})}
{/* Synced drag-to-zoom selection — mirrored across charts in the same group. */}
{syncZoomSelection && (
<ReferenceArea
x1={syncZoomSelection.start}
x2={syncZoomSelection.current}
isFront
stroke="var(--color-pending)"
strokeOpacity={0.3}
fill="var(--color-pending)"
fillOpacity={0.15}
className="pointer-events-none"
/>
)}
{/* Synced hover indicator: drawn on the *other* charts only (the hovered one
shows its own cursor); pointer-events-none so it never steals hover. */}
{syncActiveX != null && !highlight.tooltipActive && (
<ReferenceLine
x={syncActiveX}
stroke={SYNC_LINE_COLOR}
strokeWidth={1}
strokeDasharray="4 4"
isFront
className="pointer-events-none"
/>
)}
{/* Horizontal reference line */}
{referenceLine && (
<ReferenceLine
y={referenceLine.value}
label={{
position: "top",
value: referenceLine.label,
fill: "var(--color-text-dimmed)",
fontSize: 11,
}}
isFront={true}
stroke="var(--color-border-bright)"
strokeDasharray="4 4"
className="pointer-events-none"
/>
)}
{/* Zoom inspection line - rendered after bars to appear on top */}
{enableZoom && zoom?.inspectionLine && (
<ReferenceLine
x={zoom.inspectionLine}
stroke="var(--color-text-bright)"
strokeWidth={2}
isFront={true}
onClick={(e: any) => {
e?.stopPropagation?.();
zoom.clearInspectionLine();
}}
/>
)}
{/* Note: Legend is now rendered by ChartRoot outside the chart container */}
</BarChart>
);
}
@@ -0,0 +1,23 @@
import { cn } from "~/utils/cn";
import { Paragraph } from "../Paragraph";
export function ChartBlankState({
icon: Icon,
message,
className,
}: {
icon?: React.ComponentType<{ className?: string }>;
message: string;
className?: string;
}) {
return (
<div className={cn("flex h-full w-full items-center justify-center", className)}>
<div className="-mt-3 flex flex-col items-center gap-2">
{Icon && <Icon className="size-12 text-tertiary" />}
<Paragraph variant="small" className="text-text-dimmed/70">
{message}
</Paragraph>
</div>
</div>
);
}
@@ -0,0 +1,95 @@
import { Maximize2 } from "lucide-react";
import { useCallback, useRef, useState, type ReactNode } from "react";
import { Button } from "~/components/primitives/Buttons";
import { ShortcutKey } from "~/components/primitives/ShortcutKey";
import { SimpleTooltip } from "~/components/primitives/Tooltip";
import { useShortcutKeys } from "~/hooks/useShortcutKeys";
import { cn } from "~/utils/cn";
import { Dialog, DialogContent, DialogHeader } from "../Dialog";
import { Card } from "./Card";
type ChartCardProps = {
/** Title shown in the card header (and the fullscreen dialog header). */
title: ReactNode;
/** Chart content. Also used in the fullscreen dialog unless `fullscreenChildren` is set. */
children: ReactNode;
/** Optional distinct content for the fullscreen dialog (defaults to `children`). */
fullscreenChildren?: ReactNode;
/** Show the maximize button + enable the fullscreen dialog. Defaults to true. */
maximizable?: boolean;
/** Extra classes for the inner Card. */
className?: string;
};
/**
* Chart card with a title and an optional "Maximize" button that opens the chart
* fullscreen. Mirrors the dashboard QueryWidget (hover-revealed button + "v" shortcut).
*/
export function ChartCard({
title,
children,
fullscreenChildren,
maximizable = true,
className,
}: ChartCardProps) {
const [isFullscreen, setIsFullscreen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
// "v" toggles fullscreen for the hovered card.
useShortcutKeys({
shortcut: { key: "v" },
action: useCallback(() => {
const isHovered = containerRef.current?.matches(":hover");
if (!isFullscreen && !isHovered) return;
setIsFullscreen((prev) => !prev);
}, [isFullscreen]),
disabled: !maximizable,
});
return (
<div ref={containerRef} className="group h-full min-h-0 overflow-hidden">
<Card className={cn("h-full overflow-hidden px-0 pb-2 pt-3", className)}>
<Card.Header>
<div className="flex items-center gap-1.5">{title}</div>
{maximizable && (
<Card.Accessory>
<SimpleTooltip
button={
<span className="opacity-0 transition-opacity group-focus-within:opacity-100 group-hover:opacity-100">
<Button
variant="minimal/small"
LeadingIcon={Maximize2}
aria-label="Maximize chart"
leadingIconClassName="text-text-dimmed group-hover/button:text-text-bright"
onClick={() => setIsFullscreen(true)}
className="px-1!"
/>
</span>
}
content={
<span className="flex items-center gap-1">
Maximize
<ShortcutKey shortcut={{ key: "v" }} variant="small/bright" />
</span>
}
asChild
/>
</Card.Accessory>
)}
</Card.Header>
<div className="min-h-0 flex-1 px-2">{children}</div>
</Card>
{maximizable && (
<Dialog open={isFullscreen} onOpenChange={setIsFullscreen}>
<DialogContent fullscreen className="flex flex-col bg-background-bright">
<DialogHeader>{title}</DialogHeader>
<div className="min-h-0 w-full flex-1 overflow-hidden pt-4">
{fullscreenChildren ?? children}
</div>
</DialogContent>
</Dialog>
)}
</div>
);
}
@@ -0,0 +1,106 @@
/**
* Compound Chart Component System
*
* This module exports a unified Chart compound component that can render
* different chart types (bar, line, etc.) with optional features like
* zoom and legends.
*
* Note: Due to recharts' component hierarchy requirements, the Legend must be
* rendered inside the chart component (BarChart, LineChart). Use the showLegend
* prop on Chart.Bar or Chart.Line instead of Chart.Legend as a sibling.
*
* @example Simple bar chart with legend
* ```tsx
* import { Chart } from "~/components/primitives/charts/ChartCompound";
*
* <Chart.Root config={config} data={data} dataKey="day">
* <Chart.Bar stackId="a" showLegend maxLegendItems={5} />
* </Chart.Root>
* ```
*
* @example Line chart
* ```tsx
* <Chart.Root config={config} data={data} dataKey="day">
* <Chart.Line lineType="step" showLegend />
* </Chart.Root>
* ```
*
* @example Bar chart with zoom (callback-based)
* ```tsx
* function MyChart() {
* const handleZoomChange = (range: ZoomRange) => {
* // Fetch new data based on range, update state
* };
*
* return (
* <Chart.Root
* config={config}
* data={data}
* dataKey="day"
* enableZoom
* onZoomChange={handleZoomChange}
* >
* <Chart.Bar stackId="a" showLegend />
* </Chart.Root>
* );
* }
* ```
*
* @example Dashboard with synced charts (using DateRangeContext)
* ```tsx
* <DateRangeProvider defaultStartDate={start} defaultEndDate={end}>
* <DashboardControls />
*
* <Chart.Root config={config1} data={data1} dataKey="day" enableZoom onZoomChange={handleZoom1}>
* <Chart.Bar showLegend />
* </Chart.Root>
*
* <Chart.Root config={config2} data={data2} dataKey="day" enableZoom onZoomChange={handleZoom2}>
* <Chart.Line showLegend />
* </Chart.Root>
* </DateRangeProvider>
* ```
*/
import { ChartRoot } from "./ChartRoot";
import { ChartBarRenderer } from "./ChartBar";
import { ChartLineRenderer } from "./ChartLine";
import { ChartLegendCompound } from "./ChartLegendCompound";
import { ChartZoom } from "./ChartZoom";
// Re-export types
export type { ChartConfig, ChartState } from "./Chart";
export type { ZoomRange } from "./hooks/useZoomSelection";
export type { ChartRootProps } from "./ChartRoot";
export type { ChartBarRendererProps } from "./ChartBar";
export type { ChartLineRendererProps } from "./ChartLine";
export type { ChartLegendCompoundProps } from "./ChartLegendCompound";
export type { ChartZoomProps } from "./ChartZoom";
/**
* Chart compound component for building flexible, composable charts.
*
* Components:
* - `Chart.Root` - Main wrapper that provides context
* - `Chart.Bar` - Bar chart renderer (use showLegend prop for legend)
* - `Chart.Line` - Line/area chart renderer (use showLegend prop for legend)
* - `Chart.Zoom` - Optional zoom overlay (rendered internally when enableZoom is set)
*
* Note: Chart.Legend is exported for advanced use cases but should typically
* be enabled via the showLegend prop on Chart.Bar or Chart.Line.
*/
export const Chart = {
Root: ChartRoot,
Bar: ChartBarRenderer,
Line: ChartLineRenderer,
Legend: ChartLegendCompound,
Zoom: ChartZoom,
};
// Also export individual components for direct imports
export { ChartRoot, ChartBarRenderer, ChartLineRenderer, ChartLegendCompound, ChartZoom };
// Re-export context hook for advanced usage
export { useChartContext } from "./ChartContext";
export { useHasNoData, useSeriesTotal } from "./ChartRoot";
export { useZoomHandlers, ZoomTooltip } from "./ChartZoom";
@@ -0,0 +1,174 @@
import React, { createContext, useCallback, useContext, useMemo, useRef, useState } from "react";
import type { ChartConfig, ChartState } from "./Chart";
import { useHighlightState, type UseHighlightStateReturn } from "./hooks/useHighlightState";
import {
useZoomSelection,
type UseZoomSelectionReturn,
type ZoomRange,
} from "./hooks/useZoomSelection";
/** Function to format the x-axis label for display */
export type LabelFormatter = (value: string) => string;
export type ChartContextValue = {
// Core data
config: ChartConfig;
data: any[];
dataKey: string;
/** Computed series keys (all config keys except dataKey) */
dataKeys: string[];
/** Subset of dataKeys actually rendered as SVG elements (defaults to dataKeys) */
visibleSeries: string[];
// Display state
state?: ChartState;
// Formatters
/** Function to format the x-axis label (used in legend, tooltips, etc.) */
labelFormatter?: LabelFormatter;
// Highlight state (does NOT include activePayload — see PayloadContext)
highlight: UseHighlightStateReturn;
/** Update the active payload for the legend. Pass tooltipIndex to skip redundant updates. */
setActivePayload: (payload: any[] | null, tooltipIndex?: number | null) => void;
// Zoom state (only present when zoom is enabled)
zoom: UseZoomSelectionReturn | null;
// Zoom callback (only present when zoom is enabled)
onZoomChange?: (range: ZoomRange) => void;
// Whether the compound legend is shown (disables tooltip when true)
showLegend: boolean;
};
const ChartCompoundContext = createContext<ChartContextValue | null>(null);
/**
* Separate context for activePayload so that frequent payload updates
* only re-render the legend, not the entire chart (bars, lines, etc.).
*/
const PayloadContext = createContext<any[] | null>(null);
export function useChartContext(): ChartContextValue {
const context = useContext(ChartCompoundContext);
if (!context) {
throw new Error("useChartContext must be used within a Chart.Root component");
}
return context;
}
/** Read the active payload (only re-renders when payload changes). */
export function useActivePayload(): any[] | null {
return useContext(PayloadContext);
}
export type ChartProviderProps = {
config: ChartConfig;
data: any[];
dataKey: string;
/** Series keys to render (if not provided, derived from config keys) */
series?: string[];
/** Subset of series to render as SVG elements on the chart (legend still shows all series) */
visibleSeries?: string[];
state?: ChartState;
/** Function to format the x-axis label (used in legend, tooltips, etc.) */
labelFormatter?: LabelFormatter;
/** Enable zoom functionality */
enableZoom?: boolean;
/** Callback when zoom range changes */
onZoomChange?: (range: ZoomRange) => void;
/** Whether the compound legend is shown (disables tooltip when true) */
showLegend?: boolean;
children: React.ReactNode;
};
export function ChartProvider({
config,
data,
dataKey,
series,
visibleSeries: visibleSeriesProp,
state,
labelFormatter,
enableZoom = false,
onZoomChange,
showLegend = false,
children,
}: ChartProviderProps) {
const highlight = useHighlightState();
const zoomState = useZoomSelection();
// activePayload lives in its own state + context so updates don't re-render bars
const [activePayload, setActivePayloadRaw] = useState<any[] | null>(null);
const activeTooltipIndexRef = useRef<number | null>(null);
const setActivePayload = useCallback((payload: any[] | null, tooltipIndex?: number | null) => {
const idx = tooltipIndex ?? null;
if (idx !== null && idx === activeTooltipIndexRef.current) {
return;
}
activeTooltipIndexRef.current = idx;
setActivePayloadRaw(payload);
}, []);
// Reset the tooltip index ref when highlight resets (mouse leaves chart)
const originalReset = highlight.reset;
const resetWithPayload = useCallback(() => {
activeTooltipIndexRef.current = null;
setActivePayloadRaw(null);
originalReset();
}, [originalReset]);
const highlightWithReset = useMemo(
() => ({ ...highlight, reset: resetWithPayload }),
[highlight, resetWithPayload]
);
// Compute series keys (use provided series or derive from config)
const dataKeys = useMemo(
() => series ?? Object.keys(config).filter((k) => k !== dataKey),
[series, config, dataKey]
);
const visibleSeries = useMemo(() => visibleSeriesProp ?? dataKeys, [visibleSeriesProp, dataKeys]);
const value = useMemo<ChartContextValue>(
() => ({
config,
data,
dataKey,
dataKeys,
visibleSeries,
state,
labelFormatter,
highlight: highlightWithReset,
setActivePayload,
zoom: enableZoom ? zoomState : null,
onZoomChange: enableZoom ? onZoomChange : undefined,
showLegend,
}),
[
config,
data,
dataKey,
dataKeys,
visibleSeries,
state,
labelFormatter,
highlightWithReset,
setActivePayload,
zoomState,
enableZoom,
onZoomChange,
showLegend,
]
);
return (
<ChartCompoundContext.Provider value={value}>
<PayloadContext.Provider value={activePayload}>{children}</PayloadContext.Provider>
</ChartCompoundContext.Provider>
);
}
@@ -0,0 +1,375 @@
import React, { useMemo } from "react";
import type { AggregationType } from "~/components/metrics/QueryWidget";
import { useActivePayload, useChartContext } from "./ChartContext";
import { useSeriesTotal } from "./ChartRoot";
import { aggregateValues } from "./aggregation";
import { cn } from "~/utils/cn";
import { AnimatedNumber } from "../AnimatedNumber";
import { SimpleTooltip } from "../Tooltip";
const aggregationLabels: Record<AggregationType, string> = {
sum: "Sum",
avg: "Average",
count: "Count",
min: "Min",
max: "Max",
};
export type ChartLegendCompoundProps = {
/** Maximum number of legend items to show before collapsing */
maxItems?: number;
/** Hide the legend entirely (useful for conditional rendering) */
hidden?: boolean;
/** Additional className */
className?: string;
/** Label for the total row (derived from aggregation when not provided) */
totalLabel?: string;
/** Aggregation method controls the header label and how totals are computed */
aggregation?: AggregationType;
/** Optional formatter for numeric values (e.g. bytes, duration) */
valueFormatter?: (value: number) => string;
/** Callback when "View all" button is clicked */
onViewAllLegendItems?: () => void;
/** When true, constrains legend to max 50% height with scrolling */
scrollable?: boolean;
};
/**
* Legend component for the chart compound system.
* Renders as a permanent element below the chart (not inside recharts).
* Automatically connects to chart context for highlighting.
*
* @example Using via Chart.Root showLegend prop (recommended)
* ```tsx
* <Chart.Root config={config} data={data} dataKey="day" showLegend maxLegendItems={5}>
* <Chart.Bar />
* </Chart.Root>
* ```
*/
export function ChartLegendCompound({
maxItems = Infinity,
hidden = false,
className,
totalLabel,
aggregation,
valueFormatter,
onViewAllLegendItems,
scrollable = false,
}: ChartLegendCompoundProps) {
const { config, dataKey, dataKeys, highlight, labelFormatter } = useChartContext();
const activePayload = useActivePayload();
const totals = useSeriesTotal(aggregation);
// Derive the effective label from the aggregation type when no explicit label is provided
const effectiveTotalLabel =
totalLabel ?? (aggregation ? aggregationLabels[aggregation] : "Total");
// Calculate grand total by aggregating across all per-series values
const grandTotal = useMemo(() => {
const values = dataKeys.map((key) => totals[key] || 0);
if (!aggregation) {
// Default: sum
return values.reduce((a, b) => a + b, 0);
}
return aggregateValues(values, aggregation);
}, [totals, dataKeys, aggregation]);
// Calculate current total based on hover state (null when hovering a gap-filled point)
const currentTotal = useMemo((): number | null => {
if (!activePayload?.length) return grandTotal;
// Use the full data row so the total covers ALL dataKeys, not just visibleSeries
const dataRow = activePayload[0]?.payload;
if (!dataRow) return grandTotal;
const rawValues = dataKeys.map((key) => dataRow[key]);
const values = rawValues.filter((v): v is number => v != null).map((v) => Number(v) || 0);
// All null → gap-filled point, return null to show dash
if (values.length === 0) return null;
if (!aggregation) {
return values.reduce((a, b) => a + b, 0);
}
return aggregateValues(values, aggregation);
}, [activePayload, grandTotal, dataKeys, aggregation]);
// Get the label for the total row - x-axis value when hovering, effectiveTotalLabel otherwise
const currentTotalLabel = useMemo(() => {
if (!activePayload?.length) return effectiveTotalLabel;
// Get the x-axis label from the payload's original data
const firstPayloadItem = activePayload[0];
const xAxisValue = firstPayloadItem?.payload?.[dataKey];
if (xAxisValue === undefined) return effectiveTotalLabel;
// Apply the formatter if provided, otherwise just stringify the value
const stringValue = String(xAxisValue);
return labelFormatter ? labelFormatter(stringValue) : stringValue;
}, [activePayload, dataKey, effectiveTotalLabel, labelFormatter]);
// Get current data for the legend based on hover state (values may be null for gap-filled points)
const currentData = useMemo((): Record<string, number | null> => {
if (!activePayload?.length) return totals;
// Use the full data row so ALL dataKeys are resolved from the hovered point,
// not just the visibleSeries present in activePayload.
const dataRow = activePayload[0]?.payload;
if (!dataRow) return totals;
const hoverData: Record<string, number | null> = {};
for (const key of dataKeys) {
const value = dataRow[key];
if (value !== undefined) {
hoverData[key] = value != null ? Number(value) || 0 : null;
}
}
return {
...totals,
...hoverData,
};
}, [activePayload, totals, dataKeys]);
// Prepare legend items with capped display
const legendItems = useMemo(() => {
const allItems = dataKeys.map((key) => ({
dataKey: key,
color: config[key]?.color,
label: config[key]?.label ?? key,
}));
if (allItems.length <= maxItems) {
return { visible: allItems, remaining: 0, hoveredHiddenItem: undefined };
}
const visibleItems = allItems.slice(0, maxItems);
const remainingCount = allItems.length - maxItems;
// If we're hovering over an item that's not visible in the legend,
// pass it separately to replace the "view more" row
let hoveredHiddenItem: (typeof allItems)[0] | undefined;
if (
highlight.activeBarKey &&
!visibleItems.some((item) => item.dataKey === highlight.activeBarKey)
) {
hoveredHiddenItem = allItems.find((item) => item.dataKey === highlight.activeBarKey);
}
return { visible: visibleItems, remaining: remainingCount, hoveredHiddenItem };
}, [config, dataKeys, maxItems, highlight.activeBarKey]);
if (hidden || dataKeys.length === 0) {
return null;
}
const isHovering = (activePayload?.length ?? 0) > 0;
return (
<div
className={cn(
"flex flex-col px-2 pb-2 pt-4 text-sm",
scrollable && "max-h-[50%] min-h-0",
className
)}
>
{/* Total row */}
<div
className={cn(
"flex w-full shrink-0 items-center justify-between gap-2 rounded px-2 py-1 transition",
isHovering ? "text-text-bright" : "text-text-dimmed"
)}
>
<span className="font-medium">{currentTotalLabel}</span>
<span className="shrink-0 font-medium tabular-nums">
{currentTotal != null ? (
valueFormatter ? (
valueFormatter(currentTotal)
) : (
<AnimatedNumber value={currentTotal} duration={0.25} />
)
) : (
"\u2013"
)}
</span>
</div>
{/* Separator */}
<div className="mx-2 my-1 shrink-0 border-t border-grid-dimmed" />
{/* Legend items - scrollable when scrollable prop is true */}
<div
className={cn(
"flex flex-col",
scrollable &&
"min-h-0 flex-1 overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control"
)}
>
{legendItems.visible.map((item) => {
const total = currentData[item.dataKey] ?? null;
const isActive = highlight.activeBarKey === item.dataKey;
return (
<div
key={item.dataKey}
className={cn(
"relative flex w-full cursor-default items-center justify-between gap-2 rounded px-2 py-1 transition",
(total == null || total === 0) && "opacity-50"
)}
onMouseEnter={() => highlight.setHoveredLegendItem(item.dataKey)}
onMouseLeave={() => highlight.reset()}
>
{/* Active highlight background */}
{isActive && item.color && (
<div
className="absolute inset-0 rounded opacity-10"
style={{ backgroundColor: item.color }}
/>
)}
<div className="relative flex w-full items-center justify-between gap-3 overflow-hidden">
<SimpleTooltip
button={
<div className="flex min-w-0 items-center gap-1.5">
{item.color && (
<div
className="w-1 shrink-0 self-stretch rounded-[2px]"
style={{ backgroundColor: item.color }}
/>
)}
<span
className={cn(
"truncate",
isActive ? "text-text-bright" : "text-text-dimmed"
)}
>
{item.label}
</span>
</div>
}
content={item.label}
side="top"
disableHoverableContent
className="max-w-xs wrap-break-word"
buttonClassName="cursor-default min-w-0"
/>
<span
className={cn(
"shrink-0 self-start tabular-nums",
isActive ? "text-text-bright" : "text-text-dimmed"
)}
>
{total != null ? (
valueFormatter ? (
valueFormatter(total)
) : (
<AnimatedNumber value={total} duration={0.25} />
)
) : (
"\u2013"
)}
</span>
</div>
</div>
);
})}
{/* View more row - replaced by hovered hidden item when applicable */}
{legendItems.remaining > 0 &&
(legendItems.hoveredHiddenItem ? (
<HoveredHiddenItemRow
item={legendItems.hoveredHiddenItem}
value={currentData[legendItems.hoveredHiddenItem.dataKey] ?? null}
remainingCount={legendItems.remaining - 1}
valueFormatter={valueFormatter}
/>
) : (
<ViewAllDataRow
remainingCount={legendItems.remaining}
onViewAll={onViewAllLegendItems}
/>
))}
</div>
</div>
);
}
type ViewAllDataRowProps = {
remainingCount: number;
onViewAll?: () => void;
};
function ViewAllDataRow({ remainingCount, onViewAll }: ViewAllDataRowProps) {
return (
<div
className="relative flex w-full cursor-pointer items-center justify-between gap-2 rounded px-2 py-1 transition hover:bg-background-dimmed"
onClick={onViewAll}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onViewAll?.();
}
}}
>
<div className="relative flex w-full items-center justify-between gap-3">
<div className="flex items-center gap-1.5">
<div className="w-1 shrink-0 self-stretch rounded-[2px] border border-border-bright" />
<span className="text-text-dimmed tabular-nums">{remainingCount} more</span>
</div>
<span className="self-start text-indigo-500">View all</span>
</div>
</div>
);
}
type HoveredHiddenItemRowProps = {
item: { dataKey: string; color?: string; label: React.ReactNode };
value: number | null;
remainingCount: number;
valueFormatter?: (value: number) => string;
};
function HoveredHiddenItemRow({
item,
value,
remainingCount,
valueFormatter,
}: HoveredHiddenItemRowProps) {
return (
<div className="relative flex w-full items-center justify-between gap-2 rounded px-2 py-1">
{/* Active highlight background */}
{item.color && (
<div
className="absolute inset-0 rounded opacity-10"
style={{ backgroundColor: item.color }}
/>
)}
<div className="relative flex w-full items-center justify-between gap-3">
<div className="flex items-center gap-1.5">
{item.color && (
<div
className="w-1 shrink-0 self-stretch rounded-[2px]"
style={{ backgroundColor: item.color }}
/>
)}
<span className="text-text-bright">{item.label}</span>
{remainingCount > 0 && <span className="text-text-dimmed">+{remainingCount} more</span>}
</div>
<span className="shrink-0 tabular-nums text-text-bright">
{value != null ? (
valueFormatter ? (
valueFormatter(value)
) : (
<AnimatedNumber value={value} duration={0.25} />
)
) : (
"\u2013"
)}
</span>
</div>
</div>
);
}
@@ -0,0 +1,239 @@
import {
Area,
AreaChart,
CartesianGrid,
Line,
LineChart,
XAxis,
YAxis,
type XAxisProps,
type YAxisProps,
} from "recharts";
import { ChartTooltip, ChartTooltipContent } from "~/components/primitives/charts/Chart";
import { CHART_MARGIN } from "./ChartBar";
import { useChartContext } from "./ChartContext";
import { ChartLineInvalid, ChartLineLoading, ChartLineNoData } from "./ChartLoading";
import { useHasNoData } from "./ChartRoot";
import { defaultYAxisTickFormatter, useYAxisWidth } from "./useYAxisWidth";
// Legend is now rendered by ChartRoot outside the chart container
type CurveType =
| "basis"
| "basisClosed"
| "basisOpen"
| "linear"
| "linearClosed"
| "natural"
| "monotoneX"
| "monotoneY"
| "monotone"
| "step"
| "stepBefore"
| "stepAfter";
// ============================================================================
// COMPOUND COMPONENT API
// ============================================================================
export type ChartLineRendererProps = {
/** Line curve type */
lineType?: CurveType;
/** Custom X-axis props to merge with defaults */
xAxisProps?: Partial<XAxisProps>;
/** Custom Y-axis props to merge with defaults */
yAxisProps?: Partial<YAxisProps>;
/** Render as stacked area chart instead of line chart */
stacked?: boolean;
/** Custom tooltip label formatter */
tooltipLabelFormatter?: (label: string, payload: any[]) => string;
/** Optional formatter for numeric tooltip values (e.g. bytes, duration) */
tooltipValueFormatter?: (value: number) => string;
/** Width injected by ResponsiveContainer */
width?: number;
/** Height injected by ResponsiveContainer */
height?: number;
};
/**
* Line chart renderer for the compound component system.
* Must be used within a Chart.Root.
*
* @example
* ```tsx
* <Chart.Root config={config} data={data} dataKey="day">
* <Chart.Line type="step" />
* <Chart.Legend simple />
* </Chart.Root>
* ```
*/
export function ChartLineRenderer({
lineType = "step",
xAxisProps: xAxisPropsProp,
yAxisProps: yAxisPropsProp,
stacked = false,
tooltipLabelFormatter,
tooltipValueFormatter,
width,
height,
}: ChartLineRendererProps) {
const {
config,
data,
dataKey,
dataKeys: _dataKeys,
visibleSeries,
state,
highlight,
setActivePayload,
showLegend,
} = useChartContext();
const hasNoData = useHasNoData();
const yAxisTickFormatter = yAxisPropsProp?.tickFormatter ?? defaultYAxisTickFormatter;
const computedYAxisWidth = useYAxisWidth(data, visibleSeries, yAxisTickFormatter);
// Render loading/error states
if (state === "loading") {
return <ChartLineLoading />;
} else if (state === "noData" || hasNoData) {
return <ChartLineNoData />;
} else if (state === "invalid") {
return <ChartLineInvalid />;
}
// Get the x-axis ticks based on tooltip state
const xAxisTicks =
highlight.tooltipActive && data.length > 2
? [data[0]?.[dataKey], data[data.length - 1]?.[dataKey]]
: undefined;
const xAxisConfig = {
dataKey,
tickLine: false,
axisLine: false,
tickMargin: 10,
ticks: xAxisTicks,
interval: "preserveStartEnd" as const,
tick: {
fill: "var(--color-text-dimmed)",
fontSize: 11,
style: { fontVariantNumeric: "tabular-nums" },
},
...xAxisPropsProp,
};
const yAxisConfig = {
axisLine: false,
tickLine: false,
tickMargin: 8,
width: computedYAxisWidth,
tick: {
fill: "var(--color-text-dimmed)",
fontSize: 11,
style: { fontVariantNumeric: "tabular-nums" },
},
tickFormatter: yAxisTickFormatter,
...yAxisPropsProp,
};
// Handle mouse leave to also reset highlight
const handleMouseLeave = () => {
highlight.setTooltipActive(false);
highlight.reset();
};
// Render stacked area chart if stacked prop is true
if (stacked && visibleSeries.length > 1) {
return (
<AreaChart
data={data}
width={width}
height={height}
stackOffset="none"
margin={CHART_MARGIN}
onMouseMove={(e: any) => {
if (e?.activePayload?.length) {
setActivePayload(e.activePayload, e.activeTooltipIndex);
highlight.setTooltipActive(true);
} else {
highlight.setTooltipActive(false);
}
}}
onMouseLeave={handleMouseLeave}
>
<CartesianGrid vertical={false} stroke="var(--color-grid-bright)" strokeDasharray="3 3" />
<XAxis {...xAxisConfig} />
<YAxis {...yAxisConfig} />
{/* When legend is shown below, render tooltip with cursor only (no content popup) */}
<ChartTooltip
cursor={{ stroke: "rgba(255, 255, 255, 0.1)", strokeWidth: 1 }}
content={
showLegend ? (
() => null
) : (
<ChartTooltipContent indicator="line" valueFormatter={tooltipValueFormatter} />
)
}
labelFormatter={tooltipLabelFormatter}
/>
{/* Note: Legend is now rendered by ChartRoot outside the chart container */}
{visibleSeries.map((key) => (
<Area
key={key}
type={lineType}
dataKey={key}
stroke={config[key]?.color}
fill={config[key]?.color}
fillOpacity={0.6}
strokeWidth={1}
stackId="stack"
isAnimationActive={false}
/>
))}
</AreaChart>
);
}
return (
<LineChart
accessibilityLayer
data={data}
width={width}
height={height}
margin={CHART_MARGIN}
onMouseMove={(e: any) => {
if (e?.activePayload?.length) {
setActivePayload(e.activePayload, e.activeTooltipIndex);
highlight.setTooltipActive(true);
} else {
highlight.setTooltipActive(false);
}
}}
onMouseLeave={handleMouseLeave}
>
<CartesianGrid vertical={false} stroke="var(--color-grid-bright)" strokeDasharray="3 3" />
<XAxis {...xAxisConfig} />
<YAxis {...yAxisConfig} />
{/* When legend is shown below, render tooltip with cursor only (no content popup) */}
<ChartTooltip
cursor={{ stroke: "rgba(255, 255, 255, 0.1)", strokeWidth: 1 }}
content={
showLegend ? () => null : <ChartTooltipContent valueFormatter={tooltipValueFormatter} />
}
labelFormatter={tooltipLabelFormatter}
/>
{/* Note: Legend is now rendered by ChartRoot outside the chart container */}
{visibleSeries.map((key) => (
<Line
key={key}
dataKey={key}
type={lineType}
stroke={config[key]?.color}
strokeWidth={1}
dot={{ r: 1.5, fill: config[key]?.color, strokeWidth: 0 }}
activeDot={{ r: 4 }}
isAnimationActive={false}
/>
))}
</LineChart>
);
}
@@ -0,0 +1,341 @@
import { motion } from "framer-motion";
import { Button } from "../Buttons";
import { Paragraph } from "../Paragraph";
import { Spinner } from "../Spinner";
import { useDateRange } from "./DateRangeContext";
import { useMemo } from "react";
import { ClientOnly } from "remix-utils/client-only";
export function ChartBarLoading() {
return (
<div className="relative grid h-full place-items-center p-2 pt-0">
<ChartBarLoadingBackground />
<div className="absolute z-10 mb-16 flex items-center gap-2">
<Spinner className="size-6" />
<Paragraph variant="base/bright">Loading data</Paragraph>
</div>
</div>
);
}
export function ChartBarNoData() {
const dateRange = useDateRange();
return (
<div className="relative grid h-full place-items-center p-2 pt-0">
<ChartBarLoadingBackground />
<div className="absolute z-10 mb-16 flex flex-col items-center gap-2">
<Paragraph variant="small/bright">No data</Paragraph>
<Paragraph variant="small" spacing>
There's no data available for the filters you've set.
</Paragraph>
{dateRange && (
<Button variant="secondary/small" onClick={dateRange.resetDateRange}>
Clear filters
</Button>
)}
</div>
</div>
);
}
export function ChartBarInvalid() {
const dateRange = useDateRange();
return (
<div className="relative grid h-full place-items-center p-2 pt-0">
<ChartBarLoadingBackground />
<div className="absolute z-10 mb-16 flex flex-col items-center gap-2">
<Paragraph variant="small/bright">Chart not applicable</Paragraph>
<Paragraph variant="small" spacing>
Your current filter settings don't apply to this chart's data type.
</Paragraph>
{dateRange && (
<Button variant="secondary/small" onClick={dateRange.resetDateRange}>
Clear filters
</Button>
)}
</div>
</div>
);
}
export function ChartLineLoading() {
return (
<div className="relative mb-16 grid h-full place-items-center p-2 pt-0">
<ChartLineLoadingBackground />
<div className="absolute z-10 flex items-center gap-2">
<Spinner className="size-6" />
<Paragraph variant="base/bright">Loading data</Paragraph>
</div>
</div>
);
}
export function ChartLineNoData() {
const dateRange = useDateRange();
return (
<div className="relative grid h-full place-items-center p-2 pt-0">
<ChartLineLoadingBackground />
<div className="absolute z-10 mb-16 flex flex-col items-center gap-2">
<Paragraph variant="small/bright">No data</Paragraph>
<Paragraph variant="small" spacing>
There's no data available for the filters you've set.
</Paragraph>
{dateRange && (
<Button variant="secondary/small" onClick={dateRange.resetDateRange}>
Clear filters
</Button>
)}
</div>
</div>
);
}
export function ChartLineInvalid() {
const dateRange = useDateRange();
return (
<div className="relative grid h-full place-items-center p-2 pt-0">
<ChartLineLoadingBackground />
<div className="absolute z-10 mb-16 flex flex-col items-center gap-2">
<Paragraph variant="small/bright">Chart not applicable</Paragraph>
<Paragraph variant="small" spacing>
Your current filter settings don't apply to this chart's data type.
</Paragraph>
{dateRange && (
<Button variant="secondary/small" onClick={dateRange.resetDateRange}>
Clear filters
</Button>
)}
</div>
</div>
);
}
function ChartBarLoadingBackground() {
return (
<motion.div
className="flex h-full w-full flex-col"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.5 }}
>
<ClientOnly fallback={<div />}>
{() => (
<>
<motion.div
className="flex flex-1 items-end gap-1"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.6, delay: 0.1 }}
>
{Array.from({ length: 30 }).map((_, i) => {
const height = Math.max(15, Math.floor(Math.random() * 100));
return (
<motion.div
key={i}
className="flex-1 rounded-sm bg-background-hover/50"
style={{ height: `${height}%` }}
initial={{ height: 0, opacity: 0 }}
animate={{ height: `${height}%`, opacity: 1 }}
transition={{
type: "spring",
stiffness: 120,
damping: 14,
mass: 1,
delay: 0.1 + i * 0.02,
}}
/>
);
})}
</motion.div>
<motion.div
className="mt-2 flex flex-col justify-center gap-1"
initial={{ y: 10, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{
type: "spring",
stiffness: 100,
damping: 15,
delay: 0.2,
}}
>
{Array.from({ length: 5 }).map((_, i) => (
<motion.div
className="flex items-center gap-1"
key={i}
initial={{ x: -10, opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
transition={{
type: "spring",
stiffness: 100,
damping: 12,
delay: 0.3 + i * 0.08,
}}
>
<div className="h-5 w-2 rounded-sm bg-background-hover/50" />
<div className="h-5 w-full rounded-sm bg-background-hover/50" />
</motion.div>
))}
</motion.div>
</>
)}
</ClientOnly>
</motion.div>
);
}
function ChartLineLoadingBackground() {
// Generate line points with configurable starting position and constraints
const generateLinePoints = (startY: number, minY: number, maxY: number) => {
const numPoints = 10;
const points = [];
let lastY = startY;
for (let i = 0; i < numPoints; i++) {
// Calculate x value that spreads points across the full width
const x = i * (9 / (numPoints - 1));
// Create less extreme variations that move smoothly
const change = Math.random() * 6 - 3; // Range from -3 to +3
const y = Math.max(minY, Math.min(maxY, lastY + change)); // Apply constraints
points.push({ x, y });
lastY = y;
}
return points;
};
// Generate points for both lines
const points = useMemo(() => generateLinePoints(30, 10, 90), []);
const secondPoints = useMemo(() => generateLinePoints(40, 30, 90), []);
const generateSmoothPath = (points: Array<{ x: number; y: number }>) => {
if (points.length < 2) return "";
let path = `M0,${50 - points[0].y}`;
// Use curve command for smooth lines
for (let i = 0; i < points.length - 1; i++) {
const x1 = points[i].x;
const y1 = 50 - points[i].y;
const x2 = points[i + 1].x;
const y2 = 50 - points[i + 1].y;
// Bezier control points (create smooth curve)
const cx1 = (x1 + x2) / 2;
const cy1 = y1;
const cx2 = (x1 + x2) / 2;
const cy2 = y2;
path += ` C${cx1},${cy1} ${cx2},${cy2} ${x2},${y2}`;
}
return path;
};
const generateAreaPath = (points: Array<{ x: number; y: number }>) => {
const curvePath = generateSmoothPath(points);
const lastX = 9;
return `${curvePath} L${lastX},50 L0,50 Z`;
};
// Component to render a line with area fill and animation
const AnimatedLine = ({
points,
gradientId,
delay = 0,
}: {
points: Array<{ x: number; y: number }>;
gradientId: string;
delay?: number;
}) => (
<>
<motion.path
d={generateAreaPath(points)}
fill={`url(#${gradientId})`}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 1, ease: "easeInOut", delay }}
/>
<motion.path
d={generateSmoothPath(points)}
stroke="var(--color-background-hover)"
strokeWidth="1"
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
initial={{ opacity: 0 }}
animate={{ opacity: 0.8 }}
transition={{ duration: 1.5, ease: "easeInOut", delay }}
vectorEffect="non-scaling-stroke"
/>
</>
);
return (
<motion.div
className="flex h-full w-full flex-col"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.5 }}
>
<div className="relative flex-1">
<svg
className="absolute inset-0 h-full w-full"
viewBox="0 0 9 50"
preserveAspectRatio="none"
>
<defs>
<linearGradient id="areaGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="var(--color-background-hover)" stopOpacity={0.3} />
<stop offset="100%" stopColor="var(--color-background-hover)" stopOpacity={0} />
</linearGradient>
<linearGradient id="secondAreaGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="var(--color-background-hover)" stopOpacity={0.3} />
<stop offset="100%" stopColor="var(--color-background-hover)" stopOpacity={0} />
</linearGradient>
</defs>
<g>
<AnimatedLine points={points} gradientId="areaGradient" />
<AnimatedLine points={secondPoints} gradientId="secondAreaGradient" delay={0.2} />
</g>
</svg>
</div>
<motion.div
className="mt-2 flex flex-col justify-center gap-1"
initial={{ y: 10, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{
type: "spring",
stiffness: 100,
damping: 15,
delay: 0.2,
}}
>
{Array.from({ length: 5 }).map((_, i) => (
<motion.div
className="flex items-center gap-1"
key={i}
initial={{ x: -10, opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
transition={{
type: "spring",
stiffness: 100,
damping: 12,
delay: 0.3 + i * 0.08,
}}
>
<div className="h-5 w-2 rounded-sm bg-background-hover/50" />
<div className="h-5 w-full rounded-sm bg-background-hover/50" />
</motion.div>
))}
</motion.div>
</motion.div>
);
}
@@ -0,0 +1,299 @@
import React, { useMemo } from "react";
import type * as RechartsPrimitive from "recharts";
import type { AggregationType } from "~/components/metrics/QueryWidget";
import { ChartContainer, type ChartConfig, type ChartState } from "./Chart";
import { ChartProvider, useChartContext, type LabelFormatter } from "./ChartContext";
import { ChartLegendCompound } from "./ChartLegendCompound";
import type { ZoomRange } from "./hooks/useZoomSelection";
import { cn } from "~/utils/cn";
export type ChartRootProps = {
config: ChartConfig;
data: any[];
dataKey: string;
/** Series keys to render (if not provided, derived from config keys) */
series?: string[];
/** Subset of series to render as SVG elements on the chart (legend still shows all series) */
visibleSeries?: string[];
state?: ChartState;
/** Function to format the x-axis label (used in legend, tooltips, etc.) */
labelFormatter?: LabelFormatter;
/** Enable zoom functionality */
enableZoom?: boolean;
/** Callback when zoom range changes */
onZoomChange?: (range: ZoomRange) => void;
/** Minimum height for the chart */
minHeight?: string;
/** Additional className for the container */
className?: string;
/** Show the compound legend below the chart */
showLegend?: boolean;
/** Maximum items in the legend before showing "view more" */
maxLegendItems?: number;
/** Label for the total row in the legend */
legendTotalLabel?: string;
/** Aggregation method used by the legend to compute totals (defaults to sum behavior) */
legendAggregation?: AggregationType;
/** Optional formatter for numeric legend values (e.g. bytes, duration) */
legendValueFormatter?: (value: number) => string;
/** Callback when "View all" legend button is clicked */
onViewAllLegendItems?: () => void;
/** When true, constrains legend to max 50% height with scrolling */
legendScrollable?: boolean;
/** Additional className for the legend */
legendClassName?: string;
/** When true, chart fills its parent container height and distributes space between chart and legend */
fillContainer?: boolean;
/** Content rendered between the chart and the legend */
beforeLegend?: React.ReactNode;
children: React.ComponentProps<typeof RechartsPrimitive.ResponsiveContainer>["children"];
};
/**
* Root component for the chart compound component system.
* Provides shared context for all child chart components.
*
* @example Simple bar chart
* ```tsx
* <Chart.Root config={config} data={data} dataKey="day">
* <Chart.Bar stackId="a" />
* <Chart.Legend />
* </Chart.Root>
* ```
*
* @example Chart with zoom
* ```tsx
* <Chart.Root config={config} data={data} dataKey="day" enableZoom onZoomChange={handleZoom}>
* <Chart.Bar stackId="a" />
* <Chart.Zoom />
* <Chart.Legend />
* </Chart.Root>
* ```
*/
export function ChartRoot({
config,
data,
dataKey,
series,
visibleSeries,
state,
labelFormatter,
enableZoom = false,
onZoomChange,
minHeight,
className,
showLegend = false,
maxLegendItems = 5,
legendTotalLabel,
legendAggregation,
legendValueFormatter,
onViewAllLegendItems,
legendScrollable = false,
legendClassName,
fillContainer = false,
beforeLegend,
children,
}: ChartRootProps) {
return (
<ChartProvider
config={config}
data={data}
dataKey={dataKey}
series={series}
visibleSeries={visibleSeries}
state={state}
labelFormatter={labelFormatter}
enableZoom={enableZoom}
onZoomChange={onZoomChange}
showLegend={showLegend}
>
<ChartRootInner
minHeight={minHeight}
className={className}
showLegend={showLegend}
maxLegendItems={maxLegendItems}
legendTotalLabel={legendTotalLabel}
legendAggregation={legendAggregation}
legendValueFormatter={legendValueFormatter}
onViewAllLegendItems={onViewAllLegendItems}
legendScrollable={legendScrollable}
legendClassName={legendClassName}
fillContainer={fillContainer}
beforeLegend={beforeLegend}
>
{children}
</ChartRootInner>
</ChartProvider>
);
}
type ChartRootInnerProps = {
minHeight?: string;
className?: string;
showLegend?: boolean;
maxLegendItems?: number;
legendTotalLabel?: string;
legendAggregation?: AggregationType;
legendValueFormatter?: (value: number) => string;
onViewAllLegendItems?: () => void;
legendScrollable?: boolean;
legendClassName?: string;
fillContainer?: boolean;
beforeLegend?: React.ReactNode;
children: React.ComponentProps<typeof RechartsPrimitive.ResponsiveContainer>["children"];
};
function ChartRootInner({
minHeight,
className,
showLegend = false,
maxLegendItems = 5,
legendTotalLabel,
legendAggregation,
legendValueFormatter,
onViewAllLegendItems,
legendScrollable = false,
legendClassName,
fillContainer = false,
beforeLegend,
children,
}: ChartRootInnerProps) {
const { config, zoom } = useChartContext();
const enableZoom = zoom !== null;
return (
<div className={cn("relative flex w-full flex-col", fillContainer && "h-full", className)}>
<div
className={cn(
fillContainer ? "min-h-0 flex-1" : "h-full w-full",
enableZoom && "mt-8 cursor-crosshair"
)}
style={{ touchAction: "none", userSelect: "none" }}
>
<ChartContainer
config={config}
className={cn(
"h-full w-full",
fillContainer && "aspect-auto!",
enableZoom &&
"[&_.recharts-surface]:cursor-crosshair [&_.recharts-wrapper]:cursor-crosshair"
)}
style={fillContainer ? undefined : minHeight ? { minHeight } : undefined}
>
{children}
</ChartContainer>
</div>
{beforeLegend}
{/* Legend rendered outside the chart container */}
{showLegend && (
<ChartLegendCompound
maxItems={maxLegendItems}
totalLabel={legendTotalLabel}
aggregation={legendAggregation}
valueFormatter={legendValueFormatter}
onViewAllLegendItems={onViewAllLegendItems}
scrollable={legendScrollable}
className={legendClassName}
/>
)}
</div>
);
}
/**
* Hook to check if all data in the visible range is empty (null or undefined).
* Zero values are considered valid data and will render.
* Useful for rendering "no data" states.
*/
export function useHasNoData(): boolean {
const { data, dataKey: _dataKey, dataKeys } = useChartContext();
return useMemo(() => {
if (data.length === 0) return true;
return data.every((item) => {
return dataKeys.every((key) => {
const value = item[key];
return value === null || value === undefined;
});
});
}, [data, dataKeys]);
}
/**
* Hook to calculate aggregated values for each series across all data points.
* When no aggregation is provided, defaults to sum (original behavior).
* Useful for legend displays.
*/
export function useSeriesTotal(aggregation?: AggregationType): Record<string, number> {
const { data, dataKeys } = useChartContext();
return useMemo(() => {
// Sum (default) and count use additive accumulation
if (!aggregation || aggregation === "sum" || aggregation === "count") {
return data.reduce(
(acc, item) => {
for (const seriesKey of dataKeys) {
acc[seriesKey] = (acc[seriesKey] || 0) + Number(item[seriesKey] || 0);
}
return acc;
},
{} as Record<string, number>
);
}
if (aggregation === "avg") {
const sums: Record<string, number> = {};
const counts: Record<string, number> = {};
for (const item of data) {
for (const seriesKey of dataKeys) {
const rawVal = item[seriesKey];
if (rawVal == null) continue; // skip gap-filled nulls
const val = Number(rawVal);
sums[seriesKey] = (sums[seriesKey] || 0) + val;
counts[seriesKey] = (counts[seriesKey] || 0) + 1;
}
}
const result: Record<string, number> = {};
for (const key of dataKeys) {
result[key] = counts[key] ? sums[key]! / counts[key]! : 0;
}
return result;
}
if (aggregation === "min") {
const result: Record<string, number> = {};
for (const item of data) {
for (const seriesKey of dataKeys) {
if (item[seriesKey] == null) continue; // skip gap-filled nulls
const val = Number(item[seriesKey]);
if (result[seriesKey] === undefined || val < result[seriesKey]) {
result[seriesKey] = val;
}
}
}
// Default to 0 for series with no data
for (const key of dataKeys) {
if (result[key] === undefined) result[key] = 0;
}
return result;
}
// aggregation === "max"
const result: Record<string, number> = {};
for (const item of data) {
for (const seriesKey of dataKeys) {
if (item[seriesKey] == null) continue; // skip gap-filled nulls
const val = Number(item[seriesKey]);
if (result[seriesKey] === undefined || val > result[seriesKey]) {
result[seriesKey] = val;
}
}
}
// Default to 0 for series with no data
for (const key of dataKeys) {
if (result[key] === undefined) result[key] = 0;
}
return result;
}, [data, dataKeys, aggregation]);
}
@@ -0,0 +1,121 @@
import React, { createContext, useCallback, useContext, useMemo, useRef, useState } from "react";
/**
* Cross-chart sync for a group of charts sharing an x-axis domain. Wrap them in one
* <ChartSyncProvider>; both behaviours mirror across every chart: a hover indicator
* (vertical line at the hovered x), and drag-to-zoom (a selection rectangle that
* commits the range via `onZoom`, e.g. to set the Time/Date filter).
*
* Chart.Bar reads this via useChartSync, a no-op when no provider is present.
* Drag-to-zoom is active only when `onZoom` is provided.
*/
type ChartSyncXValue = number | string | null;
/** Committed zoom range (epoch ms). */
export type ChartZoomRange = { start: number; end: number };
/** In-progress drag selection (raw x values; not yet ordered). */
type ZoomSelection = { start: number | string; current: number | string };
type ChartSyncContextValue = {
/** The x-axis value currently hovered in any chart in the group. */
activeX: ChartSyncXValue;
setActiveX: (x: ChartSyncXValue) => void;
/** Whether drag-to-zoom is active (an onZoom handler was provided). */
zoomEnabled: boolean;
/** Current drag selection, mirrored across charts; null when not dragging. */
zoomSelection: ZoomSelection | null;
startZoom: (x: number | string) => void;
updateZoom: (x: number | string) => void;
/** Finish the drag and commit the range (adds `bucketWidthMs` so the last bucket is included). */
endZoom: (bucketWidthMs?: number) => void;
cancelZoom: () => void;
};
const ChartSyncContext = createContext<ChartSyncContextValue | null>(null);
/**
* Turn a raw drag selection into an ordered, inclusive range. Returns null for
* a non-drag (start === current) or non-numeric selection.
*/
export function computeZoomRange(
start: number | string,
current: number | string,
bucketWidthMs = 0
): ChartZoomRange | null {
const a = Number(start);
const b = Number(current);
if (!Number.isFinite(a) || !Number.isFinite(b) || a === b) return null;
const width = Number.isFinite(bucketWidthMs) ? bucketWidthMs : 0;
return { start: Math.min(a, b), end: Math.max(a, b) + width };
}
export function ChartSyncProvider({
children,
onZoom,
}: {
children: React.ReactNode;
/** Called with the selected range when a drag-to-zoom completes. */
onZoom?: (range: ChartZoomRange) => void;
}) {
const [activeX, setActiveXState] = useState<ChartSyncXValue>(null);
const [zoomSelection, setZoomSelection] = useState<ZoomSelection | null>(null);
// Track selection synchronously so endZoom (fired on mouseup) reads the latest.
const selectionRef = useRef<ZoomSelection | null>(null);
const setActiveX = useCallback((x: ChartSyncXValue) => setActiveXState(x), []);
const startZoom = useCallback((x: number | string) => {
const next = { start: x, current: x };
selectionRef.current = next;
setZoomSelection(next);
}, []);
const updateZoom = useCallback((x: number | string) => {
const prev = selectionRef.current;
if (!prev) return;
const next = { start: prev.start, current: x };
selectionRef.current = next;
setZoomSelection(next);
}, []);
const cancelZoom = useCallback(() => {
selectionRef.current = null;
setZoomSelection(null);
}, []);
const endZoom = useCallback(
(bucketWidthMs = 0) => {
const sel = selectionRef.current;
selectionRef.current = null;
setZoomSelection(null);
if (!sel) return;
const range = computeZoomRange(sel.start, sel.current, bucketWidthMs);
if (range) onZoom?.(range);
},
[onZoom]
);
const value = useMemo<ChartSyncContextValue>(
() => ({
activeX,
setActiveX,
zoomEnabled: onZoom != null,
zoomSelection,
startZoom,
updateZoom,
endZoom,
cancelZoom,
}),
[activeX, setActiveX, onZoom, zoomSelection, startZoom, updateZoom, endZoom, cancelZoom]
);
return <ChartSyncContext.Provider value={value}>{children}</ChartSyncContext.Provider>;
}
/** Returns the sync context, or null when not inside a ChartSyncProvider. */
export function useChartSync(): ChartSyncContextValue | null {
return useContext(ChartSyncContext);
}
@@ -0,0 +1,244 @@
import React, { useCallback } from "react";
import { ReferenceArea, ReferenceLine } from "recharts";
import { useChartContext } from "./ChartContext";
import { useDateRange } from "./DateRangeContext";
import { cn } from "~/utils/cn";
export type ChartZoomProps = {
/** Sync zoom with DateRangeContext (for dashboard-level syncing) */
syncWithDateRange?: boolean;
/** Minimum number of data points required for a valid zoom selection */
minDataPoints?: number;
};
/**
* Zoom overlay component for charts.
* Renders the zoom selection area and inspection line.
*
* Must be used within a Chart.Root with enableZoom={true}.
*
* @example Basic zoom
* ```tsx
* <Chart.Root config={config} data={data} dataKey="day" enableZoom onZoomChange={handleZoom}>
* <Chart.Bar />
* <Chart.Zoom />
* </Chart.Root>
* ```
*
* @example Synced with DateRangeContext
* ```tsx
* <DateRangeProvider>
* <Chart.Root config={config} data={data} dataKey="day" enableZoom onZoomChange={handleZoom}>
* <Chart.Bar />
* <Chart.Zoom syncWithDateRange />
* </Chart.Root>
* </DateRangeProvider>
* ```
*/
export function ChartZoom({ syncWithDateRange = false, minDataPoints = 3 }: ChartZoomProps) {
const { zoom, data: _data, dataKey: _dataKey, onZoomChange: _onZoomChange } = useChartContext();
const _globalDateRange = useDateRange();
if (!zoom) {
console.warn("ChartZoom: zoom is not enabled. Add enableZoom to Chart.Root.");
return null;
}
const { inspectionLine, refAreaLeft, refAreaRight } = zoom;
return (
<>
{/* Inspection line (click to inspect) */}
{inspectionLine && (
<ReferenceLine
x={inspectionLine}
stroke="var(--color-text-bright)"
strokeWidth={2}
isFront={true}
onClick={(e: any) => {
e?.stopPropagation?.();
zoom.clearInspectionLine();
}}
/>
)}
{/* Zoom selection area */}
{refAreaLeft && refAreaRight && (
<ReferenceArea
x1={refAreaLeft}
x2={refAreaRight}
strokeOpacity={0.4}
fill="var(--color-pending)"
fillOpacity={0.2}
/>
)}
</>
);
}
/**
* Tooltip component for showing zoom selection feedback.
* Can be used inside ChartTooltip content prop.
*
* Note: This component receives zoom state as props because recharts
* may render tooltips outside the normal React tree where context isn't available.
*/
export type ZoomTooltipProps = {
active?: boolean;
payload?: any[];
label?: string;
viewBox?: {
height: number;
width: number;
};
coordinate?: {
x: number;
y: number;
};
// Zoom state passed as props (since context might not be available in tooltip)
isSelecting?: boolean;
refAreaLeft?: string | null;
refAreaRight?: string | null;
invalidSelection?: boolean;
};
export function ZoomTooltip({
active,
payload,
label,
viewBox,
coordinate,
isSelecting,
refAreaLeft,
refAreaRight,
invalidSelection,
}: ZoomTooltipProps) {
if (!active) return null;
// Show zoom range when selecting
if (isSelecting && refAreaLeft && refAreaRight) {
const message = invalidSelection
? "Zoom: Drag a wider range"
: `Zoom: ${refAreaLeft} to ${refAreaRight}`;
return (
<div
className={cn(
"absolute whitespace-nowrap rounded border px-2 py-1 text-xxs tabular-nums",
invalidSelection
? "border-amber-800 bg-amber-950 text-amber-400"
: "border-blue-800 bg-[#1B2334] text-blue-400"
)}
style={{
left: coordinate?.x,
top: viewBox?.height ? viewBox.height + 14 : 0,
transform: "translateX(-50%)",
}}
>
{message}
<div
className={cn(
"absolute top-[-5px] left-1/2 h-2 w-2 -translate-x-1/2 rotate-45",
invalidSelection
? "border-l border-t border-amber-800 bg-amber-950"
: "border-l border-t border-blue-800 bg-[#1B2334]"
)}
/>
</div>
);
}
// Default tooltip behavior (show label)
if (!payload?.length) return null;
return (
<div
className="absolute whitespace-nowrap rounded border border-border-bright bg-background-raised px-2 py-1 text-xxs tabular-nums text-text-bright"
style={{
left: coordinate?.x,
top: viewBox?.height ? viewBox.height + 13 : 0,
transform: "translateX(-50%)",
}}
>
{label}
<div className="absolute top-[-5px] left-1/2 h-2 w-2 -translate-x-1/2 rotate-45 border-l border-t border-border-bright bg-background-raised" />
</div>
);
}
/**
* Hook that returns event handlers for zoom interactions.
* Use these on your chart component (BarChart, LineChart, etc.).
*/
export function useZoomHandlers(
options: { minDataPoints?: number; syncWithDateRange?: boolean } = {}
) {
const { minDataPoints = 3, syncWithDateRange = false } = options;
const { zoom, data, dataKey, onZoomChange } = useChartContext();
const globalDateRange = useDateRange();
const handleMouseDown = useCallback(
(e: any) => {
if (!zoom || !e?.activeLabel) return;
zoom.startSelection(e.activeLabel);
},
[zoom]
);
const handleMouseMove = useCallback(
(e: any) => {
if (!zoom || !e?.activeLabel) return;
if (zoom.isSelecting) {
zoom.updateSelection(e.activeLabel, data, dataKey, minDataPoints);
}
},
[zoom, data, dataKey, minDataPoints]
);
const handleMouseUp = useCallback(() => {
if (!zoom) return;
const range = zoom.finishSelection(data, dataKey, minDataPoints);
if (range) {
// If syncing with DateRangeContext, update it
if (syncWithDateRange && globalDateRange) {
globalDateRange.setDateRange(range.start, range.end);
}
// Call the onZoomChange callback
onZoomChange?.(range);
}
}, [zoom, data, dataKey, minDataPoints, syncWithDateRange, globalDateRange, onZoomChange]);
const handleClick = useCallback(
(e: any) => {
if (!zoom || zoom.isSelecting || !e?.activeLabel) return;
zoom.toggleInspectionLine(e.activeLabel);
},
[zoom]
);
const handleMouseLeave = useCallback(() => {
if (!zoom) return;
zoom.cancelSelection();
}, [zoom]);
if (!zoom) {
return {
onMouseDown: undefined,
onMouseMove: undefined,
onMouseUp: undefined,
onClick: undefined,
onMouseLeave: undefined,
};
}
return {
onMouseDown: handleMouseDown,
onMouseMove: handleMouseMove,
onMouseUp: handleMouseUp,
onClick: handleClick,
onMouseLeave: handleMouseLeave,
};
}
@@ -0,0 +1,120 @@
import React, { createContext, useState, useContext, type ReactNode } from "react";
type DateRangeContextType = {
/** Start date as ISO string (YYYY-MM-DD) or custom format */
startDate: string;
/** End date as ISO string (YYYY-MM-DD) or custom format */
endDate: string;
setDateRange: (startDate: string, endDate: string) => void;
resetDateRange: () => void;
};
// Formatters for displaying dates
const shortDateFormatter = new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
});
const longDateFormatter = new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
/**
* Format a Date object as a short date string (e.g., "Nov 1")
*/
export function formatChartDate(date: Date): string {
return shortDateFormatter.format(date);
}
/**
* Format a Date object as a long date string (e.g., "Nov 1, 2023")
*/
export function formatChartDateLong(date: Date): string {
return longDateFormatter.format(date);
}
/**
* Convert a Date to ISO date string (YYYY-MM-DD) using local date components
*/
export function toISODateString(date: Date): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
/**
* Parse an ISO date string (YYYY-MM-DD) to a local Date object
*/
export function parseISODateString(isoString: string): Date {
const [year, month, day] = isoString.split("-").map(Number);
return new Date(year, month - 1, day);
}
/**
* Format an ISO date string for display (e.g., "2023-11-01" -> "Nov 1")
*/
export function formatISODate(isoString: string): string {
const date = parseISODateString(isoString);
return formatChartDate(date);
}
/**
* Format an ISO date string for display with year (e.g., "2023-11-01" -> "Nov 1, 2023")
*/
export function formatISODateLong(isoString: string): string {
const date = parseISODateString(isoString);
return formatChartDateLong(date);
}
const DateRangeContext = createContext<DateRangeContextType | null>(null);
export function DateRangeProvider({
children,
defaultStartDate,
defaultEndDate,
}: {
children: ReactNode;
defaultStartDate: Date;
defaultEndDate: Date;
}) {
// Store dates as ISO strings for consistent data matching
const defaultStartISO = toISODateString(defaultStartDate);
const defaultEndISO = toISODateString(defaultEndDate);
const [startDate, setStartDate] = useState<string>(defaultStartISO);
const [endDate, setEndDate] = useState<string>(defaultEndISO);
const setDateRange = (start: string, end: string) => {
setStartDate(start);
setEndDate(end);
};
const resetDateRange = () => {
setStartDate(defaultStartISO);
setEndDate(defaultEndISO);
};
return (
<DateRangeContext.Provider
value={{
startDate,
endDate,
setDateRange,
resetDateRange,
}}
>
{children}
</DateRangeContext.Provider>
);
}
export function useDateRange(): DateRangeContextType | null {
const context = useContext(DateRangeContext);
if (!context) {
return null;
}
return context;
}
@@ -0,0 +1,63 @@
/**
* X-axis tick + tooltip label formatters for the task/agent activity charts.
* ClickHouse buckets are UTC-aligned, so labels are formatted in UTC (local time
* causes off-by-one day labels). Tick selection itself lives in useXAxisTicks.
*/
const ONE_MINUTE = 60 * 1000;
const ONE_DAY = 24 * 60 * 60 * 1000;
type ActivityPoint = { bucket: number };
export function buildActivityTimeAxis(data: ActivityPoint[]) {
const range = data.length >= 2 ? data[data.length - 1].bucket - data[0].bucket : 0;
const bucketMs = data.length >= 2 ? data[1].bucket - data[0].bucket : 0;
// ≤ 1 day range → clock time, otherwise date.
const showTime = range <= ONE_DAY;
// Sub-minute buckets need seconds, or adjacent ticks collapse to the same "HH:MM".
const showSeconds = bucketMs > 0 && bucketMs < ONE_MINUTE;
const isSubDayBucket = bucketMs > 0 && bucketMs < ONE_DAY;
const tickFormatter = (value: number) => {
const date = new Date(value);
if (showTime) {
return date.toLocaleTimeString("en-US", {
hour: "2-digit",
minute: "2-digit",
...(showSeconds ? { second: "2-digit" } : {}),
hour12: false,
timeZone: "UTC",
});
}
return date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
timeZone: "UTC",
});
};
const tooltipLabelFormatter = (_label: string, payload: { payload?: { bucket?: number } }[]) => {
const ts = payload?.[0]?.payload?.bucket;
if (typeof ts !== "number" || !Number.isFinite(ts)) return _label;
const date = new Date(ts);
return isSubDayBucket
? date.toLocaleString("en-US", {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
...(showSeconds ? { second: "2-digit" } : {}),
hour12: false,
timeZone: "UTC",
})
: date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
timeZone: "UTC",
});
};
return { tickFormatter, tooltipLabelFormatter };
}
@@ -0,0 +1,23 @@
import type { AggregationType } from "~/components/metrics/QueryWidget";
/**
* Aggregate an array of numbers using the specified aggregation function.
*
* Shared utility so both QueryResultsChart (data transformation) and chart
* legend components can reuse the same logic without circular imports.
*/
export function aggregateValues(values: number[], aggregation: AggregationType): number {
if (values.length === 0) return 0;
switch (aggregation) {
case "sum":
return values.reduce((a, b) => a + b, 0);
case "avg":
return values.reduce((a, b) => a + b, 0) / values.length;
case "count":
return values.length;
case "min":
return Math.min(...values);
case "max":
return Math.max(...values);
}
}
@@ -0,0 +1,106 @@
import { useCallback, useMemo, useState } from "react";
export type HighlightState = {
/** The currently highlighted series key (e.g., "completed", "failed") */
activeBarKey: string | null;
/** The index of the specific data point being hovered (null when hovering legend) */
activeDataPointIndex: number | null;
/** Whether the tooltip is currently active */
tooltipActive: boolean;
};
export type HighlightActions = {
/** Set the hovered bar (specific data point) */
setHoveredBar: (key: string, index: number) => void;
/** Set the hovered legend item (highlights all bars of that type) */
setHoveredLegendItem: (key: string) => void;
/** Set tooltip active state */
setTooltipActive: (active: boolean) => void;
/** Reset all highlight state */
reset: () => void;
};
export type UseHighlightStateReturn = HighlightState & HighlightActions;
const initialState: HighlightState = {
activeBarKey: null,
activeDataPointIndex: null,
tooltipActive: false,
};
/**
* Hook to manage highlight state for chart elements.
* Handles both bar hover (specific data point) and legend hover (all bars of a type).
*
* activePayload is intentionally NOT managed here — it lives in a separate context
* so that payload updates (frequent during mouse movement) don't cause bar re-renders.
*/
export function useHighlightState(): UseHighlightStateReturn {
const [state, setState] = useState<HighlightState>(initialState);
const setHoveredBar = useCallback((key: string, index: number) => {
setState({
activeBarKey: key,
activeDataPointIndex: index,
tooltipActive: true,
});
}, []);
const setHoveredLegendItem = useCallback((key: string) => {
setState((prev) => {
if (prev.activeBarKey === key && prev.activeDataPointIndex === null) return prev;
return { ...prev, activeBarKey: key, activeDataPointIndex: null };
});
}, []);
const setTooltipActive = useCallback((active: boolean) => {
setState((prev) => {
if (prev.tooltipActive === active) return prev;
return { ...prev, tooltipActive: active };
});
}, []);
const reset = useCallback(() => {
setState(initialState);
}, []);
return useMemo(
() => ({
...state,
setHoveredBar,
setHoveredLegendItem,
setTooltipActive,
reset,
}),
[state, setHoveredBar, setHoveredLegendItem, setTooltipActive, reset]
);
}
/**
* Calculate the opacity for a bar based on highlight state.
* @param key - The series key of this bar
* @param dataIndex - The data point index of this bar
* @param highlight - The current highlight state
* @param dimmedOpacity - The opacity to use for dimmed bars (default 0.2)
*/
export function getBarOpacity(
key: string,
dataIndex: number,
highlight: HighlightState,
dimmedOpacity = 0.2
): number {
const { activeBarKey, activeDataPointIndex } = highlight;
// No highlight active - full opacity
if (activeBarKey === null) {
return 1;
}
// Hovering a specific bar (from chart)
if (activeDataPointIndex !== null) {
return key === activeBarKey && dataIndex === activeDataPointIndex ? 1 : dimmedOpacity;
}
// Hovering a legend item (all bars of this type)
return key === activeBarKey ? 1 : dimmedOpacity;
}

Some files were not shown because too many files have changed in this diff Show More