chore: import upstream snapshot with attribution
This commit is contained in:
@@ -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
Reference in New Issue
Block a user