180 lines
3.9 KiB
TypeScript
180 lines
3.9 KiB
TypeScript
"use client";
|
|
import { cn } from "@/lib/utils";
|
|
import * as React from "react";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { MinusIcon, TrendingUpIcon, ArrowUpIcon, ChevronUpIcon, TrendingDownIcon, ArrowDownIcon, ChevronDownIcon } from "@/components/ui/phosphor-icons";
|
|
|
|
type DeltaIconVariant = "default" | "trend" | "arrow";
|
|
type DeltaVariant = "default" | "badge";
|
|
|
|
type DeltaContextValue = {
|
|
value: number;
|
|
};
|
|
|
|
const DeltaContext = React.createContext<DeltaContextValue | null>(null);
|
|
|
|
function useDeltaValue() {
|
|
const context = React.useContext(DeltaContext);
|
|
|
|
if (!context) {
|
|
throw new Error(
|
|
"DeltaIcon and DeltaValue must be used inside a `Delta` component."
|
|
);
|
|
}
|
|
|
|
return context.value;
|
|
}
|
|
|
|
function Delta({
|
|
className,
|
|
value,
|
|
variant = "default",
|
|
...props
|
|
}: React.ComponentProps<"div"> & {
|
|
value: number;
|
|
variant?: DeltaVariant;
|
|
}) {
|
|
return (
|
|
<DeltaContext.Provider value={{ value }}>
|
|
{variant === "badge" ? (
|
|
<Badge
|
|
className={cn(
|
|
"gap-1 border-none tabular-nums [&_svg]:size-4 [&_svg]:shrink-0",
|
|
value > 0
|
|
? "bg-emerald-500/10 text-emerald-500"
|
|
: "bg-red-500/10 text-red-500",
|
|
className
|
|
)}
|
|
data-slot="delta"
|
|
variant="secondary"
|
|
{...(props as React.ComponentProps<typeof Badge>)}
|
|
/>
|
|
) : (
|
|
<div
|
|
className={cn(
|
|
"inline-flex items-center gap-1 text-muted-foreground tabular-nums",
|
|
"[&_svg]:size-3 [&_svg]:shrink-0",
|
|
value > 0 ? "text-emerald-600 dark:text-emerald-400" : "",
|
|
value < 0 ? "text-rose-600 dark:text-rose-400" : "",
|
|
className
|
|
)}
|
|
data-slot="delta"
|
|
{...props}
|
|
/>
|
|
)}
|
|
</DeltaContext.Provider>
|
|
);
|
|
}
|
|
|
|
function FilledShell({
|
|
value,
|
|
children,
|
|
}: {
|
|
value: number;
|
|
children: React.ReactNode;
|
|
}) {
|
|
return (
|
|
<span
|
|
className={cn(
|
|
"inline-flex size-3 shrink-0 items-center justify-center rounded-full",
|
|
"[&_svg]:size-2! [&_svg]:shrink-0 [&_svg]:stroke-3! [&_svg]:text-background",
|
|
value > 0 && "bg-emerald-500",
|
|
value < 0 && "bg-red-500",
|
|
(!value || value === 0) && "bg-muted-foreground"
|
|
)}
|
|
data-slot="delta-icon"
|
|
>
|
|
{children}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
function DeltaIcon({
|
|
variant = "default",
|
|
filled = false,
|
|
className,
|
|
...props
|
|
}: Omit<React.ComponentProps<"svg">, "fill"> & {
|
|
variant?: DeltaIconVariant;
|
|
filled?: boolean;
|
|
}) {
|
|
const resolvedValue = useDeltaValue();
|
|
|
|
const mergedClassName = cn(className);
|
|
|
|
const shell = (node: React.ReactElement) =>
|
|
filled ? <FilledShell value={resolvedValue}>{node}</FilledShell> : node;
|
|
|
|
const slotProps = filled ? {} : { "data-slot": "delta-icon" as const };
|
|
|
|
if (!resolvedValue || resolvedValue === 0) {
|
|
return shell(
|
|
<MinusIcon {...slotProps} className={mergedClassName} {...props} />
|
|
);
|
|
}
|
|
|
|
if (resolvedValue > 0) {
|
|
if (variant === "trend") {
|
|
return shell(
|
|
<TrendingUpIcon {...slotProps} className={mergedClassName} {...props} />
|
|
);
|
|
}
|
|
|
|
if (variant === "arrow") {
|
|
return shell(
|
|
<ArrowUpIcon {...slotProps} className={mergedClassName} {...props} />
|
|
);
|
|
}
|
|
|
|
return shell(
|
|
<ChevronUpIcon {...slotProps} className={mergedClassName} {...props} />
|
|
);
|
|
}
|
|
|
|
if (variant === "trend") {
|
|
return shell(
|
|
<TrendingDownIcon {...slotProps} className={mergedClassName} {...props} />
|
|
);
|
|
}
|
|
|
|
if (variant === "arrow") {
|
|
return shell(
|
|
<ArrowDownIcon {...slotProps} className={mergedClassName} {...props} />
|
|
);
|
|
}
|
|
|
|
return shell(
|
|
<ChevronDownIcon {...slotProps} className={mergedClassName} {...props} />
|
|
);
|
|
}
|
|
|
|
function DeltaValue({
|
|
className,
|
|
precision = 1,
|
|
suffix = "%",
|
|
absolute = true,
|
|
...props
|
|
}: React.ComponentProps<"span"> & {
|
|
precision?: number;
|
|
suffix?: string;
|
|
absolute?: boolean;
|
|
}) {
|
|
const resolvedValue = useDeltaValue();
|
|
|
|
const formattedValue = (
|
|
absolute ? Math.abs(resolvedValue) : resolvedValue
|
|
).toFixed(precision);
|
|
|
|
return (
|
|
<span
|
|
className={cn("tabular-nums", className)}
|
|
data-slot="delta-value"
|
|
{...props}
|
|
>
|
|
{formattedValue}
|
|
{suffix}
|
|
</span>
|
|
);
|
|
}
|
|
export { Delta, DeltaIcon, DeltaValue };
|