Files
stats/apps/start/src/components/overview/overview-widget.tsx
2026-01-09 14:42:11 +01:00

312 lines
8.6 KiB
TypeScript

import { useThrottle } from '@/hooks/use-throttle';
import { cn } from '@/utils/cn';
import { ChevronsUpDownIcon, type LucideIcon, SearchIcon } from 'lucide-react';
import { last } from 'ramda';
import { Children, useCallback, useEffect, useRef, useState } from 'react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuTrigger,
} from '../ui/dropdown-menu';
import { Input } from '../ui/input';
import type { WidgetHeadProps, WidgetTitleProps } from '../widget';
import { WidgetHead as WidgetHeadBase } from '../widget';
export function WidgetHead({ className, ...props }: WidgetHeadProps) {
return (
<WidgetHeadBase
className={cn(
'relative flex flex-col rounded-t-xl p-0 [&_.title]:flex [&_.title]:items-center [&_.title]:p-4 [&_.title]:font-semibold',
className,
)}
{...props}
/>
);
}
export function WidgetTitle({
children,
className,
icon: Icon,
...props
}: WidgetTitleProps & {
icon?: LucideIcon;
}) {
return (
<div
className={cn('title text-left row justify-start', className)}
{...props}
>
{Icon && (
<div className="rounded-lg bg-def-200 p-1 mr-2">
<Icon size={16} />
</div>
)}
{children}
</div>
);
}
export function WidgetAbsoluteButtons({
className,
children,
...props
}: WidgetHeadProps) {
return (
<div
className={cn(
'row gap-1 absolute right-4 top-1/2 -translate-y-1/2',
className,
)}
{...props}
>
{children}
</div>
);
}
export function WidgetButtons({
className,
children,
...props
}: WidgetHeadProps) {
const container = useRef<HTMLDivElement>(null);
const sizes = useRef<number[]>([]);
const [slice, setSlice] = useState(3); // Show 3 buttons by default
const gap = 16;
const handleResize = useThrottle(() => {
if (container.current) {
if (sizes.current.length === 0) {
// Get buttons
const buttons: HTMLButtonElement[] = Array.from(
container.current.querySelectorAll('button'),
);
// Get sizes and cache them
sizes.current = buttons.map(
(button) => Math.ceil(button.offsetWidth) + gap,
);
}
const containerWidth = container.current.offsetWidth;
const buttonsWidth = sizes.current.reduce((acc, size) => acc + size, 0);
const moreWidth = (last(sizes.current) ?? 0) + gap;
if (buttonsWidth > containerWidth) {
const res = sizes.current.reduce(
(acc, size, index) => {
if (acc.size + size + moreWidth > containerWidth) {
return { index: acc.index, size: acc.size + size };
}
return { index, size: acc.size + size };
},
{ index: 0, size: 0 },
);
setSlice(res.index);
} else {
setSlice(sizes.current.length - 1);
}
}
}, 30);
useEffect(() => {
handleResize();
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, [handleResize, children]);
const hidden = '!opacity-0 absolute pointer-events-none';
return (
<div
ref={container}
className={cn(
'-mb-px -mt-2 flex flex-wrap justify-start self-stretch px-4 transition-opacity [&_button.active]:border-b-2 [&_button.active]:border-black [&_button.active]:opacity-100 dark:[&_button.active]:border-white [&_button]:whitespace-nowrap [&_button]:py-1 [&_button]:text-sm [&_button]:opacity-50',
className,
)}
style={{ gap }}
{...props}
>
{Children.map(children, (child, index) => {
return (
<div
className={cn(
'flex [&_button]:leading-normal',
slice < index ? hidden : 'opacity-100',
)}
>
{child}
</div>
);
})}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className={cn(
'flex select-none items-center gap-1',
sizes.current.length - 1 === slice ? hidden : 'opacity-50',
)}
>
More <ChevronsUpDownIcon size={12} />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="[&_button]:w-full">
<DropdownMenuGroup>
{Children.map(children, (child, index) => {
if (index <= slice) {
return null;
}
return <DropdownMenuItem asChild>{child}</DropdownMenuItem>;
})}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}
interface WidgetTab<T extends string = string> {
key: T;
label: string;
}
interface WidgetHeadSearchableProps<T extends string = string> {
tabs: WidgetTab<T>[];
activeTab: T;
className?: string;
onTabChange: (key: T) => void;
searchValue?: string;
onSearchChange?: (value: string) => void;
searchPlaceholder?: string;
}
export function WidgetHeadSearchable<T extends string>({
tabs,
className,
activeTab,
onTabChange,
searchValue,
onSearchChange,
searchPlaceholder = 'Search',
}: WidgetHeadSearchableProps<T>) {
const scrollRef = useRef<HTMLDivElement>(null);
const [showLeftGradient, setShowLeftGradient] = useState(false);
const [showRightGradient, setShowRightGradient] = useState(false);
const updateGradients = useCallback(() => {
const el = scrollRef.current;
if (!el) return;
const { scrollLeft, scrollWidth, clientWidth } = el;
const hasOverflow = scrollWidth > clientWidth;
setShowLeftGradient(hasOverflow && scrollLeft > 0);
setShowRightGradient(
hasOverflow && scrollLeft < scrollWidth - clientWidth - 1,
);
}, []);
useEffect(() => {
const el = scrollRef.current;
if (!el) return;
updateGradients();
el.addEventListener('scroll', updateGradients);
window.addEventListener('resize', updateGradients);
return () => {
el.removeEventListener('scroll', updateGradients);
window.removeEventListener('resize', updateGradients);
};
}, [updateGradients]);
// Update gradients when tabs change
useEffect(() => {
// Use RAF to ensure DOM has updated
requestAnimationFrame(updateGradients);
}, [tabs, updateGradients]);
return (
<div className={cn('border-b border-border', className)}>
{/* Scrollable tabs container */}
<div className="relative">
{/* Left gradient */}
<div
className={cn(
'pointer-events-none absolute left-0 top-0 z-10 h-full w-8 bg-gradient-to-r from-card to-transparent transition-opacity duration-200',
showLeftGradient ? 'opacity-100' : 'opacity-0',
)}
/>
{/* Scrollable tabs */}
<div
ref={scrollRef}
className="flex gap-1 overflow-x-auto px-2 py-3 hide-scrollbar"
>
{tabs.map((tab) => (
<button
key={tab.key}
type="button"
onClick={() => onTabChange(tab.key)}
className={cn(
'shrink-0 rounded-md py-1.5 text-sm font-medium transition-colors px-2',
activeTab === tab.key
? 'text-foreground'
: 'text-muted-foreground hover:bg-def-100 hover:text-foreground',
)}
>
{tab.label}
</button>
))}
</div>
{/* Right gradient */}
<div
className={cn(
'pointer-events-none absolute right-0 top-0 z-10 bottom-px w-8 bg-gradient-to-l from-card to-transparent transition-opacity duration-200',
showRightGradient ? 'opacity-100' : 'opacity-0',
)}
/>
</div>
{/* Search input */}
{onSearchChange && (
<div className="relative">
<SearchIcon className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
type="search"
placeholder={searchPlaceholder}
value={searchValue ?? ''}
onChange={(e) => onSearchChange(e.target.value)}
className="pl-9 bg-transparent border-0 text-sm rounded-none focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-foreground focus-visible:ring-offset-0 border-y"
/>
</div>
)}
</div>
);
}
export function WidgetFooter({
className,
children,
...props
}: WidgetHeadProps) {
return (
<div
className={cn(
'flex rounded-b-md border-t bg-def-100 p-2 py-1',
className,
)}
{...props}
>
{children}
</div>
);
}