diff --git a/src/components/BarChart/BarChart.tsx b/src/components/BarChart/BarChart.tsx new file mode 100644 index 0000000..d27039f --- /dev/null +++ b/src/components/BarChart/BarChart.tsx @@ -0,0 +1,855 @@ +// Tremor Raw BarChart [v0.0.0] + +"use client" + +import React from "react" +import { RiArrowLeftSLine, RiArrowRightSLine } from "@remixicon/react" +import { + Bar, + CartesianGrid, + Label, + BarChart as RechartsBarChart, + Legend as RechartsLegend, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts" +import { AxisDomain } from "recharts/types/util/types" + +import { useOnWindowResize } from "../../hooks/useOnWindowResize" +import { + AvailableChartColors, + AvailableChartColorsKeys, + constructCategoryColors, + getColorClassName, +} from "../../utils/chartColors" +import { cx } from "../../utils/cx" +import { getYAxisDomain } from "../../utils/getYAxisDomain" + +//#region Shape +export function deepEqual(obj1: any, obj2: any) { + if (obj1 === obj2) return true + + if ( + typeof obj1 !== "object" || + typeof obj2 !== "object" || + obj1 === null || + obj2 === null + ) + return false + + const keys1 = Object.keys(obj1) + const keys2 = Object.keys(obj2) + + if (keys1.length !== keys2.length) return false + + for (const key of keys1) { + if (!keys2.includes(key) || !deepEqual(obj1[key], obj2[key])) return false + } + + return true +} + +const renderShape = ( + props: any, + activeBar: any | undefined, + activeLegend: string | undefined, + layout: string, +) => { + const { fillOpacity, name, payload, value } = props + let { x, width, y, height } = props + + if (layout === "horizontal" && height < 0) { + y += height + height = Math.abs(height) // height must be a positive number + } else if (layout === "vertical" && width < 0) { + x += width + width = Math.abs(width) // width must be a positive number + } + + return ( + + ) +} + +//#region Legend + +interface LegendItemProps { + name: string + color: AvailableChartColorsKeys + onClick?: (name: string, color: AvailableChartColorsKeys) => void + activeLegend?: string +} + +const LegendItem = ({ + name, + color, + onClick, + activeLegend, +}: LegendItemProps) => { + const hasOnValueChange = !!onClick + return ( +
  • { + e.stopPropagation() + onClick?.(name, color) + }} + > + +

    + {name} +

    +
  • + ) +} + +interface ScrollButtonProps { + icon: React.ElementType + onClick?: () => void + disabled?: boolean +} + +const ScrollButton = ({ icon, onClick, disabled }: ScrollButtonProps) => { + const Icon = icon + const [isPressed, setIsPressed] = React.useState(false) + const intervalRef = React.useRef(null) + + React.useEffect(() => { + if (isPressed) { + intervalRef.current = setInterval(() => { + onClick?.() + }, 300) + } else { + clearInterval(intervalRef.current as NodeJS.Timeout) + } + return () => clearInterval(intervalRef.current as NodeJS.Timeout) + }, [isPressed, onClick]) + + React.useEffect(() => { + if (disabled) { + clearInterval(intervalRef.current as NodeJS.Timeout) + setIsPressed(false) + } + }, [disabled]) + + return ( + + ) +} + +interface LegendProps extends React.OlHTMLAttributes { + categories: string[] + colors?: AvailableChartColorsKeys[] + onClickLegendItem?: (category: string, color: string) => void + activeLegend?: string + enableLegendSlider?: boolean +} + +type HasScrollProps = { + left: boolean + right: boolean +} + +const Legend = React.forwardRef((props, ref) => { + const { + categories, + colors = AvailableChartColors, + className, + onClickLegendItem, + activeLegend, + enableLegendSlider = false, + ...other + } = props + const scrollableRef = React.useRef(null) + const [hasScroll, setHasScroll] = React.useState(null) + const [isKeyDowned, setIsKeyDowned] = React.useState(null) + const intervalRef = React.useRef(null) + + const checkScroll = React.useCallback(() => { + const scrollable = scrollableRef?.current + if (!scrollable) return + + const hasLeftScroll = scrollable.scrollLeft > 0 + const hasRightScroll = + scrollable.scrollWidth - scrollable.clientWidth > scrollable.scrollLeft + + setHasScroll({ left: hasLeftScroll, right: hasRightScroll }) + }, [setHasScroll]) + + const scrollToTest = React.useCallback( + (direction: "left" | "right") => { + const element = scrollableRef?.current + const width = element?.clientWidth ?? 0 + + if (element && enableLegendSlider) { + element.scrollTo({ + left: + direction === "left" + ? element.scrollLeft - width + : element.scrollLeft + width, + behavior: "smooth", + }) + setTimeout(() => { + checkScroll() + }, 400) + } + }, + [enableLegendSlider, checkScroll], + ) + + React.useEffect(() => { + const keyDownHandler = (key: string) => { + if (key === "ArrowLeft") { + scrollToTest("left") + } else if (key === "ArrowRight") { + scrollToTest("right") + } + } + if (isKeyDowned) { + keyDownHandler(isKeyDowned) + intervalRef.current = setInterval(() => { + keyDownHandler(isKeyDowned) + }, 300) + } else { + clearInterval(intervalRef.current as NodeJS.Timeout) + } + return () => clearInterval(intervalRef.current as NodeJS.Timeout) + }, [isKeyDowned, scrollToTest]) + + const keyDown = (e: KeyboardEvent) => { + e.stopPropagation() + if (e.key === "ArrowLeft" || e.key === "ArrowRight") { + e.preventDefault() + setIsKeyDowned(e.key) + } + } + const keyUp = (e: KeyboardEvent) => { + e.stopPropagation() + setIsKeyDowned(null) + } + + React.useEffect(() => { + const scrollable = scrollableRef?.current + if (enableLegendSlider) { + checkScroll() + scrollable?.addEventListener("keydown", keyDown) + scrollable?.addEventListener("keyup", keyUp) + } + + return () => { + scrollable?.removeEventListener("keydown", keyDown) + scrollable?.removeEventListener("keyup", keyUp) + } + }, [checkScroll, enableLegendSlider]) + + return ( +
      +
      + {categories.map((category, index) => ( + + ))} +
      + {enableLegendSlider && (hasScroll?.right || hasScroll?.left) ? ( + <> +
      + { + setIsKeyDowned(null) + scrollToTest("left") + }} + disabled={!hasScroll?.left} + /> + { + setIsKeyDowned(null) + scrollToTest("right") + }} + disabled={!hasScroll?.right} + /> +
      + + ) : null} +
    + ) +}) + +Legend.displayName = "Legend" + +const ChartLegend = ( + { payload }: any, + categoryColors: Map, + setLegendHeight: React.Dispatch>, + activeLegend: string | undefined, + onClick?: (category: string, color: string) => void, + enableLegendSlider?: boolean, + legendPosition?: "left" | "center" | "right", + yAxisWidth?: number, +) => { + const legendRef = React.useRef(null) + + useOnWindowResize(() => { + const calculateHeight = (height: number | undefined) => + height ? Number(height) + 15 : 60 + setLegendHeight(calculateHeight(legendRef.current?.clientHeight)) + }) + + const filteredPayload = payload.filter((item: any) => item.type !== "none") + + const paddingLeft = + legendPosition === "left" && yAxisWidth ? yAxisWidth - 8 : 0 + + return ( +
    + entry.value)} + colors={filteredPayload.map((entry: any) => + categoryColors.get(entry.value), + )} + onClickLegendItem={onClick} + activeLegend={activeLegend} + enableLegendSlider={enableLegendSlider} + /> +
    + ) +} + +//#region Tooltip + +interface ChartTooltipRowProps { + value: string + name: string + color: string +} + +const ChartTooltipRow = ({ value, name, color }: ChartTooltipRowProps) => ( +
    +
    +
    +

    + {value} +

    +
    +) + +interface ChartTooltipProps { + active: boolean | undefined + payload: any + label: string + categoryColors: Map + valueFormatter: (value: number) => string +} + +const ChartTooltip = ({ + active, + payload, + label, + categoryColors, + valueFormatter, +}: ChartTooltipProps) => { + if (active && payload) { + const filteredPayload = payload.filter((item: any) => item.type !== "none") + + return ( +
    +
    +

    + {label} +

    +
    + +
    + {filteredPayload.map( + ( + { value, name }: { value: number; name: string }, + index: number, + ) => ( + + ), + )} +
    +
    + ) + } + return null +} + +//#region BarChart + +type BaseEventProps = { + eventType: "category" | "bar" + categoryClicked: string + [key: string]: number | string +} + +type BarChartEventProps = BaseEventProps | null | undefined + +interface BarChartProps extends React.HTMLAttributes { + data: Record[] + index: string + categories: string[] + colors?: AvailableChartColorsKeys[] + valueFormatter?: (value: number) => string + startEndOnly?: boolean + showXAxis?: boolean + showYAxis?: boolean + showGridLines?: boolean + yAxisWidth?: number + intervalType?: "preserveStartEnd" | "equidistantPreserveStart" + showTooltip?: boolean + showLegend?: boolean + autoMinValue?: boolean + minValue?: number + maxValue?: number + allowDecimals?: boolean + onValueChange?: (value: BarChartEventProps) => void + enableLegendSlider?: boolean + tickGap?: number + barCategoryGap?: string | number + xAxisLabel?: string + yAxisLabel?: string + layout?: "vertical" | "horizontal" + type?: "default" | "stacked" | "percent" + legendPosition?: "left" | "center" | "right" +} + +const BarChart = React.forwardRef( + (props, forwardedRef) => { + const { + data = [], + categories = [], + index, + colors = AvailableChartColors, + valueFormatter = (value: number) => value.toString(), + startEndOnly = false, + showXAxis = true, + showYAxis = true, + showGridLines = true, + yAxisWidth = 56, + intervalType = "equidistantPreserveStart", + showTooltip = true, + showLegend = true, + autoMinValue = false, + minValue, + maxValue, + allowDecimals = true, + className, + onValueChange, + enableLegendSlider = false, + barCategoryGap, + tickGap = 5, + xAxisLabel, + yAxisLabel, + layout = "horizontal", + type = "default", + legendPosition = "right", + ...other + } = props + const paddingValue = !showXAxis && !showYAxis ? 0 : 20 + const [legendHeight, setLegendHeight] = React.useState(60) + const [activeLegend, setActiveLegend] = React.useState( + undefined, + ) + const categoryColors = constructCategoryColors(categories, colors) + const [activeBar, setActiveBar] = React.useState(undefined) + const yAxisDomain = getYAxisDomain(autoMinValue, minValue, maxValue) + const hasOnValueChange = !!onValueChange + const stacked = type === "stacked" || type === "percent" + function valueToPercent(value: number) { + return `${(value * 100).toFixed(0)}%` + } + + function onBarClick(data: any, _: any, event: React.MouseEvent) { + event.stopPropagation() + if (!onValueChange) return + if (deepEqual(activeBar, { ...data.payload, value: data.value })) { + setActiveLegend(undefined) + setActiveBar(undefined) + onValueChange?.(null) + } else { + setActiveLegend(data.tooltipPayload?.[0]?.dataKey) + setActiveBar({ + ...data.payload, + value: data.value, + }) + onValueChange?.({ + eventType: "bar", + categoryClicked: data.tooltipPayload?.[0]?.dataKey, + ...data.payload, + }) + } + } + + function onCategoryClick(dataKey: string) { + if (!hasOnValueChange) return + if (dataKey === activeLegend && !activeBar) { + setActiveLegend(undefined) + onValueChange?.(null) + } else { + setActiveLegend(dataKey) + onValueChange?.({ + eventType: "category", + categoryClicked: dataKey, + }) + } + setActiveBar(undefined) + } + + return ( +
    + + { + setActiveBar(undefined) + setActiveLegend(undefined) + onValueChange?.(null) + } + : undefined + } + margin={{ + bottom: xAxisLabel ? 30 : undefined, + left: yAxisLabel ? 20 : undefined, + right: yAxisLabel ? 5 : undefined, + top: 5, + }} + stackOffset={type === "percent" ? "expand" : undefined} + layout={layout} + > + {showGridLines ? ( + + ) : null} + + {xAxisLabel && ( + + )} + + + {yAxisLabel && ( + + )} + + ( + + ) + ) : ( + <> + ) + } + /> + {showLegend ? ( + + ChartLegend( + { payload }, + categoryColors, + setLegendHeight, + activeLegend, + hasOnValueChange + ? (clickedLegendItem: string) => + onCategoryClick(clickedLegendItem) + : undefined, + enableLegendSlider, + legendPosition, + yAxisWidth, + ) + } + /> + ) : null} + {categories.map((category) => ( + + renderShape(props, activeBar, activeLegend, layout) + } + onClick={onBarClick} + radius={20} + /> + ))} + + +
    + ) + }, +) + +BarChart.displayName = "BarChart" + +export { BarChart, type BarChartEventProps } diff --git a/src/components/BarChart/barchart.spec.ts b/src/components/BarChart/barchart.spec.ts new file mode 100644 index 0000000..68773f9 --- /dev/null +++ b/src/components/BarChart/barchart.spec.ts @@ -0,0 +1,63 @@ +import { expect, test } from "@playwright/test" + +test.beforeEach(async ({ page }) => { + await page.goto( + "http://localhost:6006/?path=/story/visualization-barchart--default", + ) +}) + +test.describe("Expect default bar chart", () => { + test("to be rendered", async ({ page }) => { + await expect( + page + .frameLocator('iframe[title="storybook-preview-iframe"]') + .getByTestId("bar-chart"), + ).toBeVisible() + }) + + test("to render legend two items", async ({ page }) => { + await expect( + page + .frameLocator('iframe[title="storybook-preview-iframe"]') + .locator("li") + .filter({ hasText: "SolarCells" }), + ).toBeVisible() + await expect( + page + .frameLocator('iframe[title="storybook-preview-iframe"]') + .locator("li") + .filter({ hasText: "Glass" }), + ).toBeVisible() + }) + + test("to render an x-axis", async ({ page }) => { + await expect( + page + .frameLocator('iframe[title="storybook-preview-iframe"]') + .locator(".recharts-xAxis"), + ).toHaveClass(/recharts-xAxis/) + }) + + test("to render an y-axis", async ({ page }) => { + await expect( + page + .frameLocator('iframe[title="storybook-preview-iframe"]') + .locator(".recharts-yAxis"), + ).toHaveClass(/recharts-yAxis/) + }) + + test("to render first two bars", async ({ page }) => { + await expect( + page + .frameLocator('iframe[title="storybook-preview-iframe"]') + .locator(".recharts-layer > rect") + .first(), + ).toBeVisible() + await expect( + page + .frameLocator('iframe[title="storybook-preview-iframe"]') + .locator("g:nth-child(8) > g > g > rect") + .first(), + ).toBeVisible() + }) +}) diff --git a/src/components/BarChart/barchart.stories.tsx b/src/components/BarChart/barchart.stories.tsx new file mode 100644 index 0000000..000e1f9 --- /dev/null +++ b/src/components/BarChart/barchart.stories.tsx @@ -0,0 +1,315 @@ +import type { Meta, StoryObj } from "@storybook/react" + +import { BarChart } from "./BarChart" + +const chartdata = [ + { + date: "Jan 23", + SolarCells: 2890, + Glass: 2338, + Encapsulant: 1450, + BackSheet: 1900, + Frame: 1600, + JunctionBox: 1800, + Adhesive: 1700, + }, + { + date: "Feb 23", + SolarCells: 2756, + Glass: 2103, + Encapsulant: 1200, + BackSheet: 1850, + Frame: 1700, + JunctionBox: 1750, + Adhesive: 1650, + }, + { + date: "Mar 23", + SolarCells: 3322, + Glass: 2194, + Encapsulant: 1300, + BackSheet: 2200, + Frame: 1400, + JunctionBox: 2000, + Adhesive: 800, + }, + { + date: "Apr 23", + SolarCells: 3470, + Glass: 2108, + Encapsulant: 1400, + BackSheet: 1600, + Frame: 1800, + JunctionBox: 1900, + Adhesive: -1950, + }, + { + date: "May 23", + SolarCells: 3475, + Glass: 1812, + Encapsulant: 1550, + BackSheet: 2300, + Frame: 1450, + JunctionBox: 2200, + Adhesive: -1600, + }, + { + date: "Jun 23", + SolarCells: 3129, + Glass: 1726, + Encapsulant: 1350, + BackSheet: 2100, + Frame: 1750, + JunctionBox: 2050, + Adhesive: -1700, + }, + { + date: "Jul 23", + SolarCells: 3490, + Glass: 1982, + Encapsulant: 1450, + BackSheet: 1950, + Frame: 1500, + JunctionBox: 2300, + Adhesive: -1800, + }, + { + date: "Aug 23", + SolarCells: 2903, + Glass: 2012, + Encapsulant: 1250, + BackSheet: 1700, + Frame: 1850, + JunctionBox: 2150, + Adhesive: -1900, + }, + { + date: "Sep 23", + SolarCells: 2643, + Glass: 2342, + Encapsulant: 1400, + BackSheet: 1600, + Frame: 1500, + JunctionBox: 2000, + Adhesive: -3750, + }, + { + date: "Oct 23", + SolarCells: 2837, + Glass: 2473, + Encapsulant: 1350, + BackSheet: 1850, + Frame: 1900, + JunctionBox: 2100, + Adhesive: -2600, + }, + { + date: "Nov 23", + SolarCells: 2954, + Glass: 3848, + Encapsulant: 1200, + BackSheet: 2000, + Frame: 1750, + JunctionBox: 2400, + Adhesive: -2950, + }, + { + date: "Dec 23", + SolarCells: 3239, + Glass: 3736, + Encapsulant: 1550, + BackSheet: 1700, + Frame: 1600, + JunctionBox: 2250, + Adhesive: -3800, + }, +] + +const meta: Meta = { + title: "visualization/BarChart", + component: BarChart, + args: { data: chartdata, index: "date", categories: ["SolarCells", "Glass"] }, +} + +export default meta +type Story = StoryObj + +export const Default: Story = { + render: () => ( + + ), +} + +export const DefaultNegative: Story = { + args: { + categories: ["SolarCells", "Adhesive"], + }, +} + +export const WithValueFormatter: Story = { + args: { + valueFormatter: (v) => `$${Intl.NumberFormat("us").format(v).toString()}`, + }, +} + +export const WithAxisLabels: Story = { + args: { + xAxisLabel: "Month of Year", + yAxisLabel: "Revenue", + }, +} + +export const WithMinValue: Story = { + args: { + autoMinValue: true, + }, +} + +export const WithMinAndMaxValue: Story = { + args: { + maxValue: 5000, + minValue: -3000, + }, +} + +export const AllColors: Story = { + args: { + data: chartdata, + index: "date", + categories: [ + "SolarCells", + "Glass", + "Encapsulant", + "BackSheet", + "Frame", + "JunctionBox", + "Adhesive", + ], + }, +} + +export const WithLegendLeft: Story = { + args: { + legendPosition: "left", + }, +} + +export const WithLegendCenter: Story = { + args: { + legendPosition: "center", + }, +} + +export const WithLegendSlider: Story = { + args: { + className: "max-w-md", + data: chartdata, + index: "date", + categories: [ + "SolarCells", + "Glass", + "Encapsulant", + "BackSheet", + "Frame", + "JunctionBox", + "Adhesive", + ], + enableLegendSlider: true, + onValueChange: (v) => console.log(v), + }, +} + +export const ShiftColors: Story = { + args: { + colors: ["amber", "cyan"], + }, +} + +export const WithStartEndOnly: Story = { + args: { + startEndOnly: true, + }, +} + +export const WithNoAxis: Story = { + args: { + showXAxis: false, + showYAxis: false, + }, +} + +export const WithNoGridlines: Story = { + args: { + showGridLines: false, + }, +} + +export const WithNoLegend: Story = { + args: { + showLegend: false, + }, +} + +export const WithNoTooltip: Story = { + args: { + showTooltip: false, + }, +} + +export const WithOnValueChange: Story = { + args: { + onValueChange: (v) => console.log(v), + }, +} + +export const WithLargeTickGap: Story = { + args: { + tickGap: 300, + }, +} + +export const WithLayoutVertical: Story = { + args: { + categories: ["SolarCells"], + layout: "vertical", + }, +} + +export const WithTypePercent: Story = { + args: { + type: "percent", + }, +} +export const WithTypePercentVertical: Story = { + args: { + layout: "vertical", + type: "percent", + }, +} + +export const stacked: Story = { + args: { + type: "stacked", + }, +} + +export const WithTypeStackedVertical: Story = { + args: { + layout: "vertical", + type: "stacked", + }, +} + +export const OneDataValue: Story = { + args: { + data: chartdata.slice(0, 1), + index: "date", + categories: ["SolarCells", "Glass"], + onValueChange: (v) => console.log(v), + }, +} diff --git a/src/components/BarChart/changelog.md b/src/components/BarChart/changelog.md new file mode 100644 index 0000000..a0709de --- /dev/null +++ b/src/components/BarChart/changelog.md @@ -0,0 +1,5 @@ +# Tremor Raw BarChart Changelog + +## 0.0.0 + +### Changes