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 (
+
+
+ )
+}
+
+//#region Tooltip
+
+interface ChartTooltipRowProps {
+ value: string
+ name: string
+ color: string
+}
+
+const ChartTooltipRow = ({ value, name, color }: ChartTooltipRowProps) => (
+
+)
+
+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 (
+
+
+
+
+ {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