add new design (#70)
* add new design stuff * add more ui things * add more ui changes * more ui changes * add more design * update emoji
This commit is contained in:
390
packages/email-editor/src/extensions/dragHandle.ts
Normal file
390
packages/email-editor/src/extensions/dragHandle.ts
Normal file
@@ -0,0 +1,390 @@
|
||||
import { Extension } from "@tiptap/core";
|
||||
import {
|
||||
NodeSelection,
|
||||
Plugin,
|
||||
PluginKey,
|
||||
TextSelection,
|
||||
} from "@tiptap/pm/state";
|
||||
import { Fragment, Slice, Node } from "@tiptap/pm/model";
|
||||
|
||||
// @ts-ignore
|
||||
import { __serializeForClipboard, EditorView } from "@tiptap/pm/view";
|
||||
|
||||
export interface GlobalDragHandleOptions {
|
||||
/**
|
||||
* The width of the drag handle
|
||||
*/
|
||||
dragHandleWidth: number;
|
||||
|
||||
/**
|
||||
* The treshold for scrolling
|
||||
*/
|
||||
scrollTreshold: number;
|
||||
|
||||
/*
|
||||
* The css selector to query for the drag handle. (eg: '.custom-handle').
|
||||
* If handle element is found, that element will be used as drag handle. If not, a default handle will be created
|
||||
*/
|
||||
dragHandleSelector?: string;
|
||||
|
||||
/**
|
||||
* Tags to be excluded for drag handle
|
||||
*/
|
||||
excludedTags: string[];
|
||||
}
|
||||
function absoluteRect(node: Element) {
|
||||
const data = node.getBoundingClientRect();
|
||||
const modal = node.closest('[role="dialog"]');
|
||||
|
||||
if (modal && window.getComputedStyle(modal).transform !== "none") {
|
||||
const modalRect = modal.getBoundingClientRect();
|
||||
|
||||
return {
|
||||
top: data.top - modalRect.top,
|
||||
left: data.left - modalRect.left,
|
||||
width: data.width,
|
||||
};
|
||||
}
|
||||
return {
|
||||
top: data.top,
|
||||
left: data.left,
|
||||
width: data.width,
|
||||
};
|
||||
}
|
||||
|
||||
function nodeDOMAtCoords(coords: { x: number; y: number }) {
|
||||
return document
|
||||
.elementsFromPoint(coords.x, coords.y)
|
||||
.find(
|
||||
(elem: Element) =>
|
||||
elem.parentElement?.matches?.(".ProseMirror") ||
|
||||
elem.matches(
|
||||
[
|
||||
"li",
|
||||
"p:not(:first-child)",
|
||||
"pre",
|
||||
"blockquote",
|
||||
"h1, h2, h3, h4, h5, h6",
|
||||
].join(", ")
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function nodePosAtDOM(
|
||||
node: Element,
|
||||
view: EditorView,
|
||||
options: GlobalDragHandleOptions
|
||||
) {
|
||||
const boundingRect = node.getBoundingClientRect();
|
||||
|
||||
return view.posAtCoords({
|
||||
left: boundingRect.left + 50 + options.dragHandleWidth,
|
||||
top: boundingRect.top + 1,
|
||||
})?.inside;
|
||||
}
|
||||
|
||||
function calcNodePos(pos: number, view: EditorView) {
|
||||
const $pos = view.state.doc.resolve(pos);
|
||||
if ($pos.depth > 1) return $pos.before($pos.depth);
|
||||
return pos;
|
||||
}
|
||||
|
||||
export function DragHandlePlugin(
|
||||
options: GlobalDragHandleOptions & { pluginKey: string }
|
||||
) {
|
||||
let listType = "";
|
||||
function handleDragStart(event: DragEvent, view: EditorView) {
|
||||
view.focus();
|
||||
|
||||
if (!event.dataTransfer) return;
|
||||
|
||||
const node = nodeDOMAtCoords({
|
||||
x: event.clientX + 50 + options.dragHandleWidth,
|
||||
y: event.clientY,
|
||||
});
|
||||
|
||||
if (!(node instanceof Element)) return;
|
||||
|
||||
let draggedNodePos = nodePosAtDOM(node, view, options);
|
||||
if (draggedNodePos == null || draggedNodePos < 0) return;
|
||||
draggedNodePos = calcNodePos(draggedNodePos, view);
|
||||
|
||||
const { from, to } = view.state.selection;
|
||||
const diff = from - to;
|
||||
|
||||
const fromSelectionPos = calcNodePos(from, view);
|
||||
let differentNodeSelected = false;
|
||||
|
||||
const nodePos = view.state.doc.resolve(fromSelectionPos);
|
||||
|
||||
// Check if nodePos points to the top level node
|
||||
if (nodePos.node().type.name === "doc") differentNodeSelected = true;
|
||||
else {
|
||||
const nodeSelection = NodeSelection.create(
|
||||
view.state.doc,
|
||||
nodePos.before()
|
||||
);
|
||||
|
||||
// Check if the node where the drag event started is part of the current selection
|
||||
differentNodeSelected = !(
|
||||
draggedNodePos + 1 >= nodeSelection.$from.pos &&
|
||||
draggedNodePos <= nodeSelection.$to.pos
|
||||
);
|
||||
}
|
||||
let selection = view.state.selection;
|
||||
if (
|
||||
!differentNodeSelected &&
|
||||
diff !== 0 &&
|
||||
!(view.state.selection instanceof NodeSelection)
|
||||
) {
|
||||
const endSelection = NodeSelection.create(view.state.doc, to - 1);
|
||||
selection = TextSelection.create(
|
||||
view.state.doc,
|
||||
draggedNodePos,
|
||||
endSelection.$to.pos
|
||||
);
|
||||
} else {
|
||||
selection = NodeSelection.create(view.state.doc, draggedNodePos);
|
||||
|
||||
// if inline node is selected, e.g mention -> go to the parent node to select the whole node
|
||||
// if table row is selected, go to the parent node to select the whole node
|
||||
if (
|
||||
(selection as NodeSelection).node.type.isInline ||
|
||||
(selection as NodeSelection).node.type.name === "tableRow"
|
||||
) {
|
||||
let $pos = view.state.doc.resolve(selection.from);
|
||||
selection = NodeSelection.create(view.state.doc, $pos.before());
|
||||
}
|
||||
}
|
||||
view.dispatch(view.state.tr.setSelection(selection));
|
||||
|
||||
// If the selected node is a list item, we need to save the type of the wrapping list e.g. OL or UL
|
||||
if (
|
||||
view.state.selection instanceof NodeSelection &&
|
||||
view.state.selection.node.type.name === "listItem"
|
||||
) {
|
||||
listType = node.parentElement!.tagName;
|
||||
}
|
||||
|
||||
const slice = view.state.selection.content();
|
||||
const { dom, text } = __serializeForClipboard(view, slice);
|
||||
|
||||
event.dataTransfer.clearData();
|
||||
event.dataTransfer.setData("text/html", dom.innerHTML);
|
||||
event.dataTransfer.setData("text/plain", text);
|
||||
event.dataTransfer.effectAllowed = "copyMove";
|
||||
|
||||
event.dataTransfer.setDragImage(node, 0, 0);
|
||||
|
||||
view.dragging = { slice, move: event.ctrlKey };
|
||||
}
|
||||
|
||||
let dragHandleElement: HTMLElement | null = null;
|
||||
|
||||
function hideDragHandle() {
|
||||
if (dragHandleElement) {
|
||||
dragHandleElement.classList.add("hide");
|
||||
}
|
||||
}
|
||||
|
||||
function showDragHandle() {
|
||||
if (dragHandleElement) {
|
||||
dragHandleElement.classList.remove("hide");
|
||||
}
|
||||
}
|
||||
|
||||
function hideHandleOnEditorOut(event: MouseEvent) {
|
||||
if (event.target instanceof Element) {
|
||||
const isInsideEditor = !!event.target.closest(".tiptap.ProseMirror");
|
||||
const isHandle =
|
||||
!!event.target.attributes.getNamedItem("data-drag-handle");
|
||||
if (isInsideEditor || isHandle) return;
|
||||
}
|
||||
hideDragHandle();
|
||||
}
|
||||
|
||||
return new Plugin({
|
||||
key: new PluginKey(options.pluginKey),
|
||||
view: (view) => {
|
||||
const handleBySelector = options.dragHandleSelector
|
||||
? document.querySelector<HTMLElement>(options.dragHandleSelector)
|
||||
: null;
|
||||
dragHandleElement = handleBySelector ?? document.createElement("div");
|
||||
dragHandleElement.draggable = true;
|
||||
dragHandleElement.dataset.dragHandle = "";
|
||||
dragHandleElement.classList.add("drag-handle");
|
||||
|
||||
function onDragHandleDragStart(e: DragEvent) {
|
||||
handleDragStart(e, view);
|
||||
}
|
||||
|
||||
dragHandleElement.addEventListener("dragstart", onDragHandleDragStart);
|
||||
|
||||
function onDragHandleDrag(e: DragEvent) {
|
||||
hideDragHandle();
|
||||
let scrollY = window.scrollY;
|
||||
if (e.clientY < options.scrollTreshold) {
|
||||
window.scrollTo({ top: scrollY - 30, behavior: "smooth" });
|
||||
} else if (window.innerHeight - e.clientY < options.scrollTreshold) {
|
||||
window.scrollTo({ top: scrollY + 30, behavior: "smooth" });
|
||||
}
|
||||
}
|
||||
|
||||
dragHandleElement.addEventListener("drag", onDragHandleDrag);
|
||||
|
||||
hideDragHandle();
|
||||
|
||||
if (!handleBySelector) {
|
||||
view?.dom?.parentElement?.appendChild(dragHandleElement);
|
||||
}
|
||||
view?.dom?.parentElement?.parentElement?.addEventListener(
|
||||
"mouseleave",
|
||||
hideHandleOnEditorOut
|
||||
);
|
||||
|
||||
return {
|
||||
destroy: () => {
|
||||
if (!handleBySelector) {
|
||||
dragHandleElement?.remove?.();
|
||||
}
|
||||
dragHandleElement?.removeEventListener("drag", onDragHandleDrag);
|
||||
dragHandleElement?.removeEventListener(
|
||||
"dragstart",
|
||||
onDragHandleDragStart
|
||||
);
|
||||
dragHandleElement = null;
|
||||
view?.dom?.parentElement?.parentElement?.removeEventListener(
|
||||
"mouseleave",
|
||||
hideHandleOnEditorOut
|
||||
);
|
||||
},
|
||||
};
|
||||
},
|
||||
props: {
|
||||
handleDOMEvents: {
|
||||
mousemove: (view, event) => {
|
||||
if (!view.editable) {
|
||||
return;
|
||||
}
|
||||
|
||||
const node = nodeDOMAtCoords({
|
||||
x: event.clientX + 50 + options.dragHandleWidth,
|
||||
y: event.clientY,
|
||||
});
|
||||
|
||||
const notDragging = node?.closest(".not-draggable");
|
||||
const excludedTagList = options.excludedTags
|
||||
.concat(["ol", "ul"])
|
||||
.join(", ");
|
||||
|
||||
if (
|
||||
!(node instanceof Element) ||
|
||||
node.matches(excludedTagList) ||
|
||||
notDragging
|
||||
) {
|
||||
hideDragHandle();
|
||||
return;
|
||||
}
|
||||
|
||||
const compStyle = window.getComputedStyle(node);
|
||||
const parsedLineHeight = parseInt(compStyle.lineHeight, 10);
|
||||
const lineHeight = isNaN(parsedLineHeight)
|
||||
? parseInt(compStyle.fontSize) * 1.2
|
||||
: parsedLineHeight;
|
||||
const paddingTop = parseInt(compStyle.paddingTop, 10);
|
||||
|
||||
const rect = absoluteRect(node);
|
||||
|
||||
rect.top += (lineHeight - 24) / 2;
|
||||
rect.top += paddingTop;
|
||||
// Li markers
|
||||
if (node.matches("ul:not([data-type=taskList]) li, ol li")) {
|
||||
rect.left -= options.dragHandleWidth;
|
||||
}
|
||||
rect.width = options.dragHandleWidth;
|
||||
|
||||
if (!dragHandleElement) return;
|
||||
|
||||
dragHandleElement.style.left = `${rect.left - rect.width}px`;
|
||||
dragHandleElement.style.top = `${rect.top}px`;
|
||||
showDragHandle();
|
||||
},
|
||||
keydown: () => {
|
||||
hideDragHandle();
|
||||
},
|
||||
mousewheel: () => {
|
||||
hideDragHandle();
|
||||
},
|
||||
// dragging class is used for CSS
|
||||
dragstart: (view) => {
|
||||
view.dom.classList.add("dragging");
|
||||
},
|
||||
drop: (view, event) => {
|
||||
view.dom.classList.remove("dragging");
|
||||
hideDragHandle();
|
||||
let droppedNode: Node | null = null;
|
||||
const dropPos = view.posAtCoords({
|
||||
left: event.clientX,
|
||||
top: event.clientY,
|
||||
});
|
||||
|
||||
if (!dropPos) return;
|
||||
|
||||
if (view.state.selection instanceof NodeSelection) {
|
||||
droppedNode = view.state.selection.node;
|
||||
}
|
||||
if (!droppedNode) return;
|
||||
|
||||
const resolvedPos = view.state.doc.resolve(dropPos.pos);
|
||||
|
||||
const isDroppedInsideList =
|
||||
resolvedPos.parent.type.name === "listItem";
|
||||
|
||||
// If the selected node is a list item and is not dropped inside a list, we need to wrap it inside <ol> tag otherwise ol list items will be transformed into ul list item when dropped
|
||||
if (
|
||||
view.state.selection instanceof NodeSelection &&
|
||||
view.state.selection.node.type.name === "listItem" &&
|
||||
!isDroppedInsideList &&
|
||||
listType == "OL"
|
||||
) {
|
||||
const newList = view.state.schema.nodes.orderedList?.createAndFill(
|
||||
null,
|
||||
droppedNode
|
||||
);
|
||||
const slice = new Slice(Fragment.from(newList), 0, 0);
|
||||
view.dragging = { slice, move: event.ctrlKey };
|
||||
}
|
||||
},
|
||||
dragend: (view) => {
|
||||
view.dom.classList.remove("dragging");
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const GlobalDragHandle = Extension.create({
|
||||
name: "globalDragHandle",
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
dragHandleWidth: 20,
|
||||
scrollTreshold: 100,
|
||||
excludedTags: [],
|
||||
};
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
DragHandlePlugin({
|
||||
pluginKey: "globalDragHandle",
|
||||
dragHandleWidth: this.options.dragHandleWidth,
|
||||
scrollTreshold: this.options.scrollTreshold,
|
||||
dragHandleSelector: this.options.dragHandleSelector,
|
||||
excludedTags: this.options.excludedTags,
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
export default GlobalDragHandle;
|
@@ -10,7 +10,7 @@ import { Color } from "@tiptap/extension-color";
|
||||
import TaskItem from "@tiptap/extension-task-item";
|
||||
import TaskList from "@tiptap/extension-task-list";
|
||||
import Placeholder from "@tiptap/extension-placeholder";
|
||||
import GlobalDragHandle from "tiptap-extension-global-drag-handle";
|
||||
import GlobalDragHandle from "./dragHandle";
|
||||
import { ButtonExtension } from "./ButtonExtension";
|
||||
import { SlashCommand, getSlashCommandSuggestions } from "./SlashCommand";
|
||||
import { VariableExtension } from "./VariableExtension";
|
||||
|
@@ -64,8 +64,8 @@ const config = {
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
"accordion-down": "accordion-down 0.2s ease-out",
|
||||
"accordion-up": "accordion-up 0.2s ease-out",
|
||||
"accordion-down": "accordion-down 0.4s ease-out",
|
||||
"accordion-up": "accordion-up 0.4s ease-out",
|
||||
},
|
||||
// fontFamily: {
|
||||
// sans: ["var(--font-geist-sans)"],
|
||||
|
@@ -1,4 +1,4 @@
|
||||
import { cn } from "./lib/utils";
|
||||
|
||||
export { cn };
|
||||
export { ThemeProvider } from "next-themes";
|
||||
export { ThemeProvider, useTheme } from "next-themes";
|
||||
|
@@ -30,6 +30,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^3.3.4",
|
||||
"@radix-ui/react-accordion": "^1.2.0",
|
||||
"@radix-ui/react-dialog": "^1.0.5",
|
||||
"@radix-ui/react-dropdown-menu": "^2.0.6",
|
||||
"@radix-ui/react-label": "^2.0.2",
|
||||
@@ -44,12 +45,14 @@
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.0",
|
||||
"cmdk": "^1.0.0",
|
||||
"framer-motion": "^11.0.24",
|
||||
"input-otp": "^1.2.4",
|
||||
"lucide-react": "^0.359.0",
|
||||
"next-themes": "^0.3.0",
|
||||
"pnpm": "^8.15.5",
|
||||
"react-hook-form": "^7.51.3",
|
||||
"react-syntax-highlighter": "^15.5.0",
|
||||
"recharts": "^2.12.5",
|
||||
"sonner": "^1.4.41",
|
||||
"tailwind-merge": "^2.2.2",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
|
55
packages/ui/src/accordion.tsx
Normal file
55
packages/ui/src/accordion.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as AccordionPrimitive from "@radix-ui/react-accordion";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
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("", className)} {...props} />
|
||||
));
|
||||
AccordionItem.displayName = "AccordionItem";
|
||||
|
||||
const AccordionTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof AccordionPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<AccordionPrimitive.Header className="flex">
|
||||
<AccordionPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<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 text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
|
||||
{...props}
|
||||
>
|
||||
<div className={cn("pb-4 pt-0", className)}>{children}</div>
|
||||
</AccordionPrimitive.Content>
|
||||
));
|
||||
|
||||
AccordionContent.displayName = AccordionPrimitive.Content.displayName;
|
||||
|
||||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
|
@@ -5,7 +5,7 @@ import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border disabled:pointer-events-none disabled:opacity-50",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
|
365
packages/ui/src/charts.tsx
Normal file
365
packages/ui/src/charts.tsx
Normal file
@@ -0,0 +1,365 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as RechartsPrimitive from "recharts";
|
||||
|
||||
import { cn } from "../lib/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 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 (
|
||||
<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) => {
|
||||
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 === 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,
|
||||
ChartStyle,
|
||||
};
|
@@ -11,7 +11,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/10 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
|
@@ -19,7 +19,7 @@ const SelectTrigger = React.forwardRef<
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-9 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
"flex h-9 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus-visible:ring-primary/10 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
@@ -25,7 +25,7 @@ export const TextWithCopyButton: React.FC<{
|
||||
<div className={className}>{value}</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={`hover:bg-transparent p-0 cursor-pointer text-muted-foreground ${
|
||||
className={`hover:bg-transparent p-0 h-6 cursor-pointer text-muted-foreground ${
|
||||
alwaysShowCopy ? "opacity-100" : "opacity-0 group-hover:opacity-100"
|
||||
}`}
|
||||
onClick={copyToClipboard}
|
||||
|
@@ -26,14 +26,20 @@
|
||||
--accent: 210 40% 96.1%;
|
||||
--accent-foreground: 222.2 47.4% 11.2%;
|
||||
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive: 0 84% 60%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
|
||||
--border: 214 1% 71%;
|
||||
--border: 220 14% 96%;
|
||||
--input: 214.3 31.8% 91.4%;
|
||||
--ring: 222.2 84% 4.9%;
|
||||
|
||||
--radius: 0.5rem;
|
||||
|
||||
--chart-1: 12 76% 61%;
|
||||
--chart-2: 173 58% 39%;
|
||||
--chart-3: 197 37% 24%;
|
||||
--chart-4: 43 74% 66%;
|
||||
--chart-5: 27 87% 67%;
|
||||
}
|
||||
|
||||
.dark {
|
||||
@@ -64,6 +70,12 @@
|
||||
--border: 217.2 32.6% 17.5%;
|
||||
--input: 217.2 32.6% 17.5%;
|
||||
--ring: 217.2 32.6% 17.5%;
|
||||
|
||||
--chart-1: 220 70% 50%;
|
||||
--chart-2: 160 60% 45%;
|
||||
--chart-3: 30 80% 55%;
|
||||
--chart-4: 280 65% 60%;
|
||||
--chart-5: 340 75% 55%;
|
||||
}
|
||||
}
|
||||
|
||||
|
Reference in New Issue
Block a user