ui and bug fixers 29/09
This commit is contained in:
@@ -19,6 +19,12 @@ interface ComboboxProps<Value> {
|
||||
emptyText?: string
|
||||
disabled?: boolean
|
||||
className?: string
|
||||
/** Extra classes for the dropdown popup — e.g. a larger `min-w-*` so long labels fit
|
||||
* when the trigger sits in a narrow table cell. */
|
||||
popupClassName?: string
|
||||
/** Always open below the input (never flip above it when space is short) — the list
|
||||
* then scrolls within the space that is left below. */
|
||||
openDownward?: boolean
|
||||
"aria-invalid"?: boolean
|
||||
}
|
||||
|
||||
@@ -32,6 +38,8 @@ export function Combobox<Value>({
|
||||
emptyText = "No results found.",
|
||||
disabled,
|
||||
className,
|
||||
popupClassName,
|
||||
openDownward,
|
||||
...rest
|
||||
}: ComboboxProps<Value>) {
|
||||
const itemToStringLabel = React.useCallback(
|
||||
@@ -65,10 +73,18 @@ export function Combobox<Value>({
|
||||
/>
|
||||
</ComboboxPrimitive.InputGroup>
|
||||
<ComboboxPrimitive.Portal>
|
||||
<ComboboxPrimitive.Positioner side="bottom" sideOffset={4} className="isolate z-50">
|
||||
<ComboboxPrimitive.Positioner
|
||||
side="bottom"
|
||||
sideOffset={4}
|
||||
collisionAvoidance={openDownward ? { side: "none", align: "shift", fallbackAxisSide: "none" } : undefined}
|
||||
className="isolate z-50"
|
||||
>
|
||||
<ComboboxPrimitive.Popup
|
||||
data-slot="combobox-content"
|
||||
className="max-h-(--available-height) w-(--anchor-width) min-w-48 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95"
|
||||
className={cn(
|
||||
"max-h-(--available-height) w-(--anchor-width) min-w-48 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
popupClassName
|
||||
)}
|
||||
>
|
||||
<ComboboxPrimitive.Empty className="px-2.5 py-6 text-center text-sm text-muted-foreground">
|
||||
{emptyText}
|
||||
|
||||
@@ -7,6 +7,10 @@ export interface KpiTileSpec {
|
||||
label: string
|
||||
value: number
|
||||
icon?: LucideIcon
|
||||
/** Makes the tile clickable — e.g. to filter the list below it to this metric. */
|
||||
onClick?: () => void
|
||||
/** Highlights the tile as the currently-applied filter. Only meaningful with `onClick`. */
|
||||
active?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -17,12 +21,19 @@ export interface KpiTileSpec {
|
||||
*/
|
||||
export function KpiRow({ tiles }: { tiles: KpiTileSpec[] | null }) {
|
||||
return (
|
||||
<div className="mb-4 grid grid-cols-1 gap-3 sm:grid-cols-4">
|
||||
<div className="mb-4 flex flex-wrap gap-2">
|
||||
{tiles
|
||||
? tiles.map((tile) => (
|
||||
<StatCard key={tile.label} label={tile.label} value={tile.value} icon={tile.icon} />
|
||||
<StatCard
|
||||
key={tile.label}
|
||||
label={tile.label}
|
||||
value={tile.value}
|
||||
icon={tile.icon}
|
||||
onClick={tile.onClick}
|
||||
active={tile.active}
|
||||
/>
|
||||
))
|
||||
: Array.from({ length: 2 }).map((_, i) => <Skeleton key={i} className="h-[90px] rounded-xl" />)}
|
||||
: Array.from({ length: 2 }).map((_, i) => <Skeleton key={i} className="h-22.5 w-64 rounded-xl" />)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -14,55 +14,39 @@ function formatValue(value: string | number) {
|
||||
return formatted.replace(/(k|K|m|M|b|B)$/u, (s) => s.toUpperCase())
|
||||
}
|
||||
|
||||
function Sparkline({ points }: { points: number[] }) {
|
||||
const width = 48
|
||||
const height = 18
|
||||
const min = Math.min(...points)
|
||||
const max = Math.max(...points)
|
||||
const range = max - min || 1
|
||||
|
||||
const coords = points.map((point, index) => {
|
||||
const x = (index / (points.length - 1)) * width
|
||||
const y = height - ((point - min) / range) * height
|
||||
return [x, y] as const
|
||||
})
|
||||
|
||||
const path = coords.map(([x, y], i) => `${i === 0 ? "M" : "L"}${x},${y}`).join(" ")
|
||||
const [lastX, lastY] = coords[coords.length - 1]
|
||||
|
||||
return (
|
||||
<svg
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
className="h-4.5 w-12 shrink-0 overflow-visible"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d={path} fill="none" className="stroke-muted-foreground/40" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
|
||||
<circle cx={lastX} cy={lastY} r={2.5} className="fill-primary stroke-card" strokeWidth={1.5} />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export interface StatCardProps {
|
||||
label: string
|
||||
value: string | number
|
||||
icon?: LucideIcon
|
||||
/** Small subtitle under the value, e.g. "All time". */
|
||||
caption?: string
|
||||
delta?: number
|
||||
deltaLabel?: string
|
||||
/** Set true for metrics where an increase is bad (e.g. churn, costs). */
|
||||
invert?: boolean
|
||||
trend?: number[]
|
||||
className?: string
|
||||
/** Makes the card an actual button — e.g. to filter the list below it to this metric. */
|
||||
onClick?: () => void
|
||||
/** Highlights the card as the currently-applied filter. Only meaningful with `onClick`. */
|
||||
active?: boolean
|
||||
/** "sm" shrinks padding, icon and text — for tight rows of many cards (e.g. the Dashboard
|
||||
* home overview) that need to fit without wrapping or scrolling. Defaults to "default". */
|
||||
size?: "default" | "sm"
|
||||
}
|
||||
|
||||
/** Icon in a circular badge on the left; label, value and an optional caption stacked to its right. */
|
||||
export function StatCard({
|
||||
label,
|
||||
value,
|
||||
icon: Icon,
|
||||
caption,
|
||||
delta,
|
||||
deltaLabel,
|
||||
invert = false,
|
||||
trend,
|
||||
className,
|
||||
onClick,
|
||||
active = false,
|
||||
size = "default",
|
||||
}: StatCardProps) {
|
||||
const hasDelta = typeof delta === "number" && delta !== 0
|
||||
const isPositive = hasDelta && delta > 0
|
||||
@@ -70,39 +54,56 @@ export function StatCard({
|
||||
|
||||
return (
|
||||
<div
|
||||
role={onClick ? "button" : undefined}
|
||||
tabIndex={onClick ? 0 : undefined}
|
||||
onClick={onClick}
|
||||
onKeyDown={
|
||||
onClick
|
||||
? (e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault()
|
||||
onClick()
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
className={cn(
|
||||
"flex flex-col gap-1.5 rounded-xl bg-card p-3.5 shadow-sm ring-1 ring-foreground/10 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-lg hover:ring-primary/30",
|
||||
"flex w-fit max-w-full shrink-0 items-center justify-self-start rounded-2xl bg-card shadow-sm ring-1 ring-foreground/10 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-lg hover:ring-primary/30",
|
||||
size === "sm" ? "min-w-52 gap-3.5 p-3.5" : "min-w-64 gap-3.5 p-4",
|
||||
onClick && "cursor-pointer outline-none focus-visible:ring-2 focus-visible:ring-primary",
|
||||
active && "ring-2 ring-primary hover:ring-primary",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<p className="text-sm font-medium text-muted-foreground">{label}</p>
|
||||
{Icon && (
|
||||
<div className="flex size-7 shrink-0 items-center justify-center rounded-lg bg-primary/10">
|
||||
<Icon className="size-3.5 text-primary" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-end justify-between gap-2">
|
||||
<p className="text-lg font-bold tracking-tight text-foreground">{formatValue(value)}</p>
|
||||
{trend && trend.length > 1 && <Sparkline points={trend} />}
|
||||
</div>
|
||||
|
||||
{hasDelta && (
|
||||
<div className="flex items-center gap-1 text-xs">
|
||||
<span
|
||||
className={cn(
|
||||
"font-semibold",
|
||||
isGood ? "text-success" : "text-destructive"
|
||||
)}
|
||||
>
|
||||
{isPositive ? "+" : "-"}
|
||||
{Math.abs(delta).toFixed(1)}%
|
||||
</span>
|
||||
{deltaLabel && <span className="text-muted-foreground">{deltaLabel}</span>}
|
||||
{Icon && (
|
||||
<div
|
||||
className={cn(
|
||||
"flex shrink-0 items-center justify-center rounded-full bg-primary/10",
|
||||
size === "sm" ? "size-9" : "size-9"
|
||||
)}
|
||||
>
|
||||
<Icon className={size === "sm" ? "size-4 text-primary" : "size-4 text-primary"} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<p className={cn("truncate font-medium text-muted-foreground", size === "sm" ? "text-sm" : "text-sm")}>{label}</p>
|
||||
<p className={cn("font-bold tracking-tight text-foreground", size === "sm" ? "text-xl" : "text-xl")}>
|
||||
{formatValue(value)}
|
||||
</p>
|
||||
|
||||
{hasDelta ? (
|
||||
<div className="flex items-center gap-1 text-xs">
|
||||
<span className={cn("font-semibold", isGood ? "text-success" : "text-destructive")}>
|
||||
{isPositive ? "+" : "-"}
|
||||
{Math.abs(delta).toFixed(1)}%
|
||||
</span>
|
||||
{deltaLabel && <span className="text-muted-foreground">{deltaLabel}</span>}
|
||||
</div>
|
||||
) : (
|
||||
caption && <p className="text-xs text-muted-foreground">{caption}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user