Design Updates: Breadcrumbs, Empty State, new Charts and new colors
Design Updates: Breadcrumbs, Empty State, new Charts and new colors * Add Breadcrumb component to UI package * Add AppBreadcrumbs for improved navigation: Replaced static text descriptions with the new AppBreadcrumbs component across multiple pages to enhance navigation. Addressed an issue with Supabase client warnings by temporarily suppressing getSession warnings. Also made minor UI adjustments, including adjustments to heading styles and layout features. * Enhance UI styling and configuration settings: Updated various UI components and global styles to improve styling consistency and responsiveness. * Update global styles and adjust padding: Updated several CSS variables for improved color accuracy and appearance. Added padding to admin account page body for better layout consistency. * Refactor UI components and adjust styling: Replaced Heading tags in Plan Picker with span for consistency. Added active and hover states to buttons in the sidebar. Refined background, layout styling, and color schemes across various components. Removed sidebar case in Page component switch statement. * Add Chart Components and Integrate into Dashboard: Introduced `recharts` library and created `Chart` components. Updated dashboard to use the new components and enhanced UI/UX with descriptions and restructured cards. * Enhance dashboard demo UI layout: Refactor the layout by adjusting flex properties and spacing classes to improve component alignment. Update dummy data generation and Figure font size for better visual consistency. * Update localization keys for navigation labels: Changed localization keys for tab labels to use 'routes' prefix for consistency. Adjusted corresponding component references and added missing keys for routes. This ensures better organization and uniformity in the code. * Add EmptyState component and enhance account handling: Introduced a new EmptyState component for UI consistency and updated JSON locales with 'account' route. Modified HomeAddAccountButton to accept className prop and refactored HomeAccountsListEmptyState to use the new EmptyState component. Updated navigation config to align labels in locales. * Add locale support and enhance currency formatting: This commit introduces locale-based currency formatting across billing components by utilizing the `useTranslation` hook to fetch the current language. It also refactors the `formatCurrency` function to accept an object parameter for better readability and reusability. * Fix typo in devDependencies section of template generator: Corrected a syntax error in `package.json.hbs` template affecting the `@kit/tsconfig` entry. The change ensures that the dependency is properly defined and prevents potential issues during package management. * Update heading levels and add tracking-tight class in auth shell: Changed Heading components from level 4 to level 5 and added the 'tracking-tight' class in multiple auth-related pages. This improves visual consistency and better aligns the typography across the application.
This commit is contained in:
committed by
GitHub
parent
23154c366d
commit
e696f1aed0
88
packages/ui/src/makerkit/app-breadcrumbs.tsx
Normal file
88
packages/ui/src/makerkit/app-breadcrumbs.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
'use client';
|
||||
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbEllipsis,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbSeparator,
|
||||
} from '../shadcn/breadcrumb';
|
||||
import { If } from './if';
|
||||
import { Trans } from './trans';
|
||||
import { Fragment } from 'react';
|
||||
|
||||
const unslugify = (slug: string) => slug.replace(/-/g, ' ');
|
||||
|
||||
export function AppBreadcrumbs(props: {
|
||||
values?: Record<string, string>;
|
||||
maxDepth?: number;
|
||||
}) {
|
||||
const pathName = usePathname();
|
||||
const splitPath = pathName.split('/').filter(Boolean);
|
||||
const values = props.values ?? {};
|
||||
const maxDepth = props.maxDepth ?? 6;
|
||||
|
||||
const Ellipsis = (
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbEllipsis className="h-4 w-4" />
|
||||
</BreadcrumbItem>
|
||||
);
|
||||
|
||||
const showEllipsis = splitPath.length > maxDepth;
|
||||
|
||||
const visiblePaths = showEllipsis
|
||||
? ([splitPath[0], ...splitPath.slice(-maxDepth + 1)] as string[])
|
||||
: splitPath;
|
||||
|
||||
return (
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
{visiblePaths.map((path, index) => {
|
||||
const label =
|
||||
path in values ? (
|
||||
values[path]
|
||||
) : (
|
||||
<Trans
|
||||
i18nKey={`common.routes.${unslugify(path)}`}
|
||||
defaults={unslugify(path)}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<Fragment key={index}>
|
||||
<BreadcrumbItem className={'capitalize lg:text-xs'}>
|
||||
<If
|
||||
condition={index < visiblePaths.length - 1}
|
||||
fallback={label}
|
||||
>
|
||||
<BreadcrumbLink
|
||||
href={
|
||||
'/' +
|
||||
splitPath.slice(0, splitPath.indexOf(path) + 1).join('/')
|
||||
}
|
||||
>
|
||||
{label}
|
||||
</BreadcrumbLink>
|
||||
</If>
|
||||
</BreadcrumbItem>
|
||||
|
||||
{index === 0 && showEllipsis && (
|
||||
<>
|
||||
<BreadcrumbSeparator />
|
||||
{Ellipsis}
|
||||
</>
|
||||
)}
|
||||
|
||||
<If condition={index !== visiblePaths.length - 1}>
|
||||
<BreadcrumbSeparator />
|
||||
</If>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
);
|
||||
}
|
||||
84
packages/ui/src/makerkit/empty-state.tsx
Normal file
84
packages/ui/src/makerkit/empty-state.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import React from 'react';
|
||||
|
||||
import { Button } from '../shadcn/button';
|
||||
import { cn } from '../utils';
|
||||
|
||||
const EmptyStateHeading = React.forwardRef<
|
||||
HTMLHeadingElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn('text-2xl font-bold tracking-tight', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
EmptyStateHeading.displayName = 'EmptyStateHeading';
|
||||
|
||||
const EmptyStateText = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<p
|
||||
ref={ref}
|
||||
className={cn('text-sm text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
EmptyStateText.displayName = 'EmptyStateText';
|
||||
|
||||
const EmptyStateButton = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
React.ComponentPropsWithoutRef<typeof Button>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<Button ref={ref} className={cn('mt-4', className)} {...props} />
|
||||
));
|
||||
EmptyStateButton.displayName = 'EmptyStateButton';
|
||||
|
||||
const EmptyState = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ children, className, ...props }, ref) => {
|
||||
const childrenArray = React.Children.toArray(children);
|
||||
|
||||
const heading = childrenArray.find(
|
||||
(child) => React.isValidElement(child) && child.type === EmptyStateHeading,
|
||||
);
|
||||
|
||||
const text = childrenArray.find(
|
||||
(child) => React.isValidElement(child) && child.type === EmptyStateText,
|
||||
);
|
||||
|
||||
const button = childrenArray.find(
|
||||
(child) => React.isValidElement(child) && child.type === EmptyStateButton,
|
||||
);
|
||||
|
||||
const cmps = [EmptyStateHeading, EmptyStateText, EmptyStateButton];
|
||||
|
||||
const otherChildren = childrenArray.filter(
|
||||
(child) =>
|
||||
React.isValidElement(child) &&
|
||||
!cmps.includes(child.type as (typeof cmps)[number]),
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex flex-1 items-center justify-center rounded-lg border border-dashed shadow-sm',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="flex flex-col items-center gap-1 text-center">
|
||||
{heading}
|
||||
{text}
|
||||
{button}
|
||||
{otherChildren}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
EmptyState.displayName = 'EmptyState';
|
||||
|
||||
export { EmptyState, EmptyStateHeading, EmptyStateText, EmptyStateButton };
|
||||
@@ -7,6 +7,7 @@ export function GlobalLoader({
|
||||
fullPage = false,
|
||||
displaySpinner = true,
|
||||
displayTopLoadingBar = true,
|
||||
children,
|
||||
}: React.PropsWithChildren<{
|
||||
displayLogo?: boolean;
|
||||
fullPage?: boolean;
|
||||
@@ -26,6 +27,8 @@ export function GlobalLoader({
|
||||
}
|
||||
>
|
||||
<LoadingOverlay displayLogo={displayLogo} fullPage={fullPage} />
|
||||
|
||||
{children}
|
||||
</div>
|
||||
</If>
|
||||
</>
|
||||
|
||||
@@ -27,7 +27,7 @@ export function LoadingOverlay({
|
||||
>
|
||||
<Spinner className={spinnerClassName} />
|
||||
|
||||
<div>{children}</div>
|
||||
<div className={'text-sm text-muted-foreground'}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,9 +13,6 @@ type PageProps = React.PropsWithChildren<{
|
||||
|
||||
export function Page(props: PageProps) {
|
||||
switch (props.style) {
|
||||
case 'sidebar':
|
||||
return <PageWithSidebar {...props} />;
|
||||
|
||||
case 'header':
|
||||
return <PageWithHeader {...props} />;
|
||||
|
||||
@@ -31,7 +28,9 @@ function PageWithSidebar(props: PageProps) {
|
||||
const { Navigation, Children, MobileNavigation } = getSlotsFromPage(props);
|
||||
|
||||
return (
|
||||
<div className={cn('flex', props.className)}>
|
||||
<div
|
||||
className={cn('flex bg-gray-50/50 dark:bg-background', props.className)}
|
||||
>
|
||||
{Navigation}
|
||||
|
||||
<div
|
||||
@@ -42,7 +41,13 @@ function PageWithSidebar(props: PageProps) {
|
||||
>
|
||||
{MobileNavigation}
|
||||
|
||||
<div className={'flex flex-1 flex-col space-y-4'}>{Children}</div>
|
||||
<div
|
||||
className={
|
||||
'flex flex-1 flex-col overflow-y-auto bg-background lg:m-1.5 lg:ml-0 lg:rounded-lg lg:border'
|
||||
}
|
||||
>
|
||||
{Children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -54,7 +59,7 @@ export function PageMobileNavigation(
|
||||
}>,
|
||||
) {
|
||||
return (
|
||||
<div className={cn('w-full py-2 lg:hidden', props.className)}>
|
||||
<div className={cn('w-full py-2 lg:hidden flex items-center border-b', props.className)}>
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
@@ -72,7 +77,7 @@ function PageWithHeader(props: PageProps) {
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'dark:border-primary-900 flex h-14 items-center justify-between bg-muted/30 px-4 shadow-sm dark:shadow-primary/10 lg:justify-start',
|
||||
'flex h-14 items-center justify-between bg-muted/40 px-4 lg:shadow-sm dark:border-border dark:shadow-primary/10 lg:justify-start',
|
||||
{
|
||||
'sticky top-0 z-10 backdrop-blur-md': props.sticky ?? true,
|
||||
},
|
||||
@@ -87,13 +92,7 @@ function PageWithHeader(props: PageProps) {
|
||||
{MobileNavigation}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={
|
||||
'flex h-screen flex-1 flex-col space-y-8 px-4 py-4 lg:container'
|
||||
}
|
||||
>
|
||||
{Children}
|
||||
</div>
|
||||
<div className={'container flex flex-1 flex-col'}>{Children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -115,13 +114,11 @@ export function PageNavigation(props: React.PropsWithChildren) {
|
||||
|
||||
export function PageDescription(props: React.PropsWithChildren) {
|
||||
return (
|
||||
<h2 className={'hidden lg:block'}>
|
||||
<span
|
||||
className={'text-base font-medium leading-none text-muted-foreground'}
|
||||
>
|
||||
<div className={'h-6'}>
|
||||
<div className={'text-xs font-normal leading-none text-muted-foreground'}>
|
||||
{props.children}
|
||||
</span>
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -129,7 +126,7 @@ export function PageTitle(props: React.PropsWithChildren) {
|
||||
return (
|
||||
<h1
|
||||
className={
|
||||
'font-heading text-2xl font-semibold leading-none dark:text-white'
|
||||
'h-6 font-heading font-bold leading-none tracking-tight dark:text-white'
|
||||
}
|
||||
>
|
||||
{props.children}
|
||||
@@ -137,6 +134,10 @@ export function PageTitle(props: React.PropsWithChildren) {
|
||||
);
|
||||
}
|
||||
|
||||
export function PageHeaderActions(props: React.PropsWithChildren) {
|
||||
return <div className={'flex items-center space-x-2'}>{props.children}</div>;
|
||||
}
|
||||
|
||||
export function PageHeader({
|
||||
children,
|
||||
title,
|
||||
@@ -150,17 +151,14 @@ export function PageHeader({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-20 items-center justify-between lg:px-4',
|
||||
'flex items-center justify-between lg:px-4 py-4',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{title ? (
|
||||
<div className={'flex flex-col space-y-1.5'}>
|
||||
<PageTitle>{title}</PageTitle>
|
||||
|
||||
<PageDescription>{description}</PageDescription>
|
||||
</div>
|
||||
) : null}
|
||||
<div className={'flex flex-col'}>
|
||||
<PageDescription>{description}</PageDescription>
|
||||
<PageTitle>{title}</PageTitle>
|
||||
</div>
|
||||
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '../shadcn/avatar';
|
||||
import {cn} from "../utils";
|
||||
|
||||
type SessionProps = {
|
||||
displayName: string | null;
|
||||
@@ -9,10 +10,12 @@ type TextProps = {
|
||||
text: string;
|
||||
};
|
||||
|
||||
type ProfileAvatarProps = SessionProps | TextProps;
|
||||
type ProfileAvatarProps = (SessionProps | TextProps) & {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function ProfileAvatar(props: ProfileAvatarProps) {
|
||||
const avatarClassName = 'mx-auto w-9 h-9 group-focus:ring-2';
|
||||
const avatarClassName = cn(props.className, 'mx-auto w-9 h-9 group-focus:ring-2');
|
||||
|
||||
if ('text' in props) {
|
||||
return (
|
||||
|
||||
@@ -60,7 +60,7 @@ export function SidebarContent({
|
||||
className?: string;
|
||||
}>) {
|
||||
return (
|
||||
<div className={cn('flex w-full flex-col space-y-1.5 px-4', className)}>
|
||||
<div className={cn('flex w-full flex-col space-y-1.5 px-4 py-1', className)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
@@ -167,8 +167,9 @@ export function SidebarItem({
|
||||
return (
|
||||
<Button
|
||||
asChild
|
||||
className={cn('flex w-full text-sm shadow-none', {
|
||||
className={cn('flex w-full text-sm shadow-none active:bg-secondary/60', {
|
||||
'justify-start space-x-2.5': !collapsed,
|
||||
'hover:bg-initial': active,
|
||||
})}
|
||||
size={size}
|
||||
variant={variant}
|
||||
@@ -196,7 +197,7 @@ function getClassNameBuilder(className: string) {
|
||||
return cva(
|
||||
[
|
||||
cn(
|
||||
'flex box-content h-screen flex-col relative shadow-sm border-r',
|
||||
'flex box-content h-screen flex-col relative',
|
||||
className,
|
||||
),
|
||||
],
|
||||
|
||||
@@ -10,7 +10,7 @@ export function Spinner(
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
`h-10 w-10 animate-spin fill-primary-foreground text-primary dark:fill-primary dark:text-primary/30`,
|
||||
`h-8 w-8 animate-spin fill-primary-foreground text-primary dark:fill-primary dark:text-primary/30`,
|
||||
props.className,
|
||||
)}
|
||||
viewBox="0 0 100 101"
|
||||
@@ -27,4 +27,4 @@ export function Spinner(
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
116
packages/ui/src/shadcn/breadcrumb.tsx
Normal file
116
packages/ui/src/shadcn/breadcrumb.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { ChevronRightIcon, DotsHorizontalIcon } from '@radix-ui/react-icons';
|
||||
import { Slot } from '@radix-ui/react-slot';
|
||||
|
||||
import { cn } from '../utils';
|
||||
|
||||
const Breadcrumb = React.forwardRef<
|
||||
HTMLElement,
|
||||
React.ComponentPropsWithoutRef<'nav'> & {
|
||||
separator?: React.ReactNode;
|
||||
}
|
||||
>(({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />);
|
||||
Breadcrumb.displayName = 'Breadcrumb';
|
||||
|
||||
const BreadcrumbList = React.forwardRef<
|
||||
HTMLOListElement,
|
||||
React.ComponentPropsWithoutRef<'ol'>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ol
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
BreadcrumbList.displayName = 'BreadcrumbList';
|
||||
|
||||
const BreadcrumbItem = React.forwardRef<
|
||||
HTMLLIElement,
|
||||
React.ComponentPropsWithoutRef<'li'>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<li
|
||||
ref={ref}
|
||||
className={cn('inline-flex items-center gap-1.5', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
BreadcrumbItem.displayName = 'BreadcrumbItem';
|
||||
|
||||
const BreadcrumbLink = React.forwardRef<
|
||||
HTMLAnchorElement,
|
||||
React.ComponentPropsWithoutRef<'a'> & {
|
||||
asChild?: boolean;
|
||||
}
|
||||
>(({ asChild, className, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : 'a';
|
||||
|
||||
return (
|
||||
<Comp
|
||||
ref={ref}
|
||||
className={cn('transition-colors text-foreground hover:underline', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
BreadcrumbLink.displayName = 'BreadcrumbLink';
|
||||
|
||||
const BreadcrumbPage = React.forwardRef<
|
||||
HTMLSpanElement,
|
||||
React.ComponentPropsWithoutRef<'span'>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<span
|
||||
ref={ref}
|
||||
role="link"
|
||||
aria-disabled="true"
|
||||
aria-current="page"
|
||||
className={cn('font-normal text-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
BreadcrumbPage.displayName = 'BreadcrumbPage';
|
||||
|
||||
const BreadcrumbSeparator = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'li'>) => (
|
||||
<li
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn('[&>svg]:size-3.5', className)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? <ChevronRightIcon />}
|
||||
</li>
|
||||
);
|
||||
BreadcrumbSeparator.displayName = 'BreadcrumbSeparator';
|
||||
|
||||
const BreadcrumbEllipsis = ({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'span'>) => (
|
||||
<span
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn('flex h-9 w-9 items-center justify-center', className)}
|
||||
{...props}
|
||||
>
|
||||
<DotsHorizontalIcon className="h-4 w-4" />
|
||||
<span className="sr-only">More</span>
|
||||
</span>
|
||||
);
|
||||
BreadcrumbEllipsis.displayName = 'BreadcrumbElipssis';
|
||||
|
||||
export {
|
||||
Breadcrumb,
|
||||
BreadcrumbList,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
BreadcrumbEllipsis,
|
||||
};
|
||||
@@ -9,7 +9,7 @@ const Card = React.forwardRef<
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'rounded-xl border bg-card text-card-foreground shadow-sm',
|
||||
'rounded-xl border bg-card text-card-foreground',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
366
packages/ui/src/shadcn/chart.tsx
Normal file
366
packages/ui/src/shadcn/chart.tsx
Normal file
@@ -0,0 +1,366 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
|
||||
import * as RechartsPrimitive from 'recharts';
|
||||
|
||||
import { cn } from '../utils';
|
||||
|
||||
// Format: { THEME_NAME: CSS_SELECTOR }
|
||||
const THEMES = { light: '', dark: '.dark' } as const;
|
||||
|
||||
export type ChartConfig = {
|
||||
[k in string]: {
|
||||
label?: React.ReactNode;
|
||||
icon?: React.ComponentType;
|
||||
} & (
|
||||
| { 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(
|
||||
"flex aspect-video justify-center text-xs [&_.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-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.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 [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none",
|
||||
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;
|
||||
}
|
||||
>(
|
||||
(
|
||||
{
|
||||
active,
|
||||
payload,
|
||||
className,
|
||||
indicator = 'dot',
|
||||
hideLabel = false,
|
||||
hideIndicator = false,
|
||||
label,
|
||||
labelFormatter,
|
||||
labelClassName,
|
||||
formatter,
|
||||
color,
|
||||
nameKey,
|
||||
labelKey,
|
||||
},
|
||||
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]?.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 (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background 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(
|
||||
'flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground',
|
||||
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 && (
|
||||
<span className="font-mono font-medium tabular-nums text-foreground">
|
||||
{item.value.toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
ChartTooltipContent.displayName = 'ChartTooltip';
|
||||
|
||||
const ChartLegend = RechartsPrimitive.Legend;
|
||||
|
||||
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) => {
|
||||
/* eslint-disable @typescript-eslint/restrict-template-expressions */
|
||||
const key = `${nameKey ?? item.dataKey ?? 'value'}`;
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.value}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{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';
|
||||
|
||||
// Helper to extract item config from a payload.
|
||||
function getPayloadConfigFromPayload(
|
||||
config: ChartConfig,
|
||||
payload: unknown,
|
||||
key: string,
|
||||
) {
|
||||
if (typeof payload !== 'object' || !payload) {
|
||||
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];
|
||||
}
|
||||
|
||||
export {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
ChartStyle,
|
||||
};
|
||||
Reference in New Issue
Block a user