@zamansheikh/percentage-bar
Version:
A customizable, interactive percentage bar React component
250 lines (249 loc) • 15.6 kB
JavaScript
;
"use client";
var __assign = (this && this.__assign) || function () {
__assign = Object.assign || function(t) {
for (var s, i = 1, n = arguments.length; i < n; i++) {
s = arguments[i];
for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p))
t[p] = s[p];
}
return t;
};
return __assign.apply(this, arguments);
};
var __spreadArray = (this && this.__spreadArray) || function (to, from, pack) {
if (pack || arguments.length === 2) for (var i = 0, l = from.length, ar; i < l; i++) {
if (ar || !(i in from)) {
if (!ar) ar = Array.prototype.slice.call(from, 0, i);
ar[i] = from[i];
}
}
return to.concat(ar || Array.prototype.slice.call(from));
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.default = PercentageBar;
var jsx_runtime_1 = require("react/jsx-runtime");
var react_1 = require("react");
var utils_1 = require("./utils");
var lucide_react_1 = require("lucide-react");
// Predefined colors for new items
var COLORS = [
"bg-red-500",
"bg-blue-500",
"bg-green-500",
"bg-yellow-500",
"bg-purple-500",
"bg-pink-500",
"bg-indigo-500",
"bg-orange-500",
"bg-teal-500",
"bg-cyan-500",
"bg-lime-500",
"bg-emerald-500",
"bg-sky-500",
"bg-violet-500",
"bg-fuchsia-500",
"bg-rose-500",
"bg-amber-500",
];
function PercentageBar() {
var _a = (0, react_1.useState)([
{ id: 1, name: "Item 1", percentage: 20, color: "bg-red-500" },
{ id: 2, name: "Item 2", percentage: 20, color: "bg-blue-500" },
{ id: 3, name: "Item 3", percentage: 20, color: "bg-green-500" },
{ id: 4, name: "Item 4", percentage: 20, color: "bg-yellow-500" },
{ id: 5, name: "Item 5", percentage: 20, color: "bg-purple-500" },
]), items = _a[0], setItems = _a[1];
var _b = (0, react_1.useState)(null), draggingIndex = _b[0], setDraggingIndex = _b[1];
var _c = (0, react_1.useState)(0), startX = _c[0], setStartX = _c[1];
var _d = (0, react_1.useState)([]), startPercentages = _d[0], setStartPercentages = _d[1];
var _e = (0, react_1.useState)(null), hoveredIndex = _e[0], setHoveredIndex = _e[1];
var _f = (0, react_1.useState)(null), editingPercentage = _f[0], setEditingPercentage = _f[1];
var barRef = (0, react_1.useRef)(null);
var nextId = (0, react_1.useRef)(6); // Start from 6 since we already have 5 items
// Handle mouse down to start dragging
var handleMouseDown = function (index, e) {
e.preventDefault();
setDraggingIndex(index);
setStartX(e.clientX);
setStartPercentages(items.map(function (item) { return item.percentage; }));
};
// Handle mouse move to update percentages
var handleMouseMove = function (e) {
if (draggingIndex === null || !barRef.current)
return;
var barWidth = barRef.current.offsetWidth;
var deltaX = e.clientX - startX;
var deltaPercentage = (deltaX / barWidth) * 100;
// Calculate new percentages
var newPercentages = __spreadArray([], startPercentages, true);
// Adjust current and next item
if (draggingIndex < items.length - 1) {
// Don't allow items to go below 2% or above 98%
var maxIncrease = Math.min(startPercentages[draggingIndex + 1] - 2, 98 - startPercentages[draggingIndex]);
var maxDecrease = Math.min(startPercentages[draggingIndex] - 2, 98 - startPercentages[draggingIndex + 1]);
var clampedDelta = Math.max(Math.min(deltaPercentage, maxIncrease), -maxDecrease);
newPercentages[draggingIndex] = startPercentages[draggingIndex] + clampedDelta;
newPercentages[draggingIndex + 1] = startPercentages[draggingIndex + 1] - clampedDelta;
}
// Update items with new percentages
setItems(items.map(function (item, i) { return (__assign(__assign({}, item), { percentage: Math.round(newPercentages[i] * 10) / 10 })); }));
};
// Handle mouse up to stop dragging
var handleMouseUp = function () {
setDraggingIndex(null);
};
// Add and remove event listeners
(0, react_1.useEffect)(function () {
if (draggingIndex !== null) {
window.addEventListener("mousemove", handleMouseMove);
window.addEventListener("mouseup", handleMouseUp);
}
return function () {
window.removeEventListener("mousemove", handleMouseMove);
window.removeEventListener("mouseup", handleMouseUp);
};
}, [draggingIndex, startX, startPercentages]);
// Update item name
var updateItemName = function (id, name) {
setItems(items.map(function (item) { return (item.id === id ? __assign(__assign({}, item), { name: name }) : item); }));
};
// Update item color
var updateItemColor = function (id, color) {
setItems(items.map(function (item) { return (item.id === id ? __assign(__assign({}, item), { color: "bg-".concat(color, "-500") }) : item); }));
};
// Handle percentage input change (while typing)
var handlePercentageChange = function (index, value) {
setEditingPercentage({ index: index, value: value });
};
// Apply percentage change when input is complete
var applyPercentageChange = function (index) {
if (!editingPercentage || editingPercentage.index !== index)
return;
// Convert to number and validate
var newPercentage = Number.parseFloat(editingPercentage.value);
// If not a valid number, revert to the current percentage
if (isNaN(newPercentage)) {
setEditingPercentage(null);
return;
}
// Create a copy of the current items
var newItems = __spreadArray([], items, true);
var oldPercentage = newItems[index].percentage;
var difference = newPercentage - oldPercentage;
// If there's no change, return early
if (difference === 0) {
setEditingPercentage(null);
return;
}
// Determine which item to adjust
var adjustIndex;
if (index === items.length - 1) {
// If it's the last item, adjust the second-to-last item
adjustIndex = items.length - 2;
}
else {
// Otherwise, adjust the next item
adjustIndex = index + 1;
}
// Calculate the new percentage for the adjusted item
var adjustedItemNewPercentage = newItems[adjustIndex].percentage - difference;
// Validate the changes
if (newPercentage < 2 || adjustedItemNewPercentage < 2) {
alert("Percentage cannot be less than 2%");
setEditingPercentage(null);
return;
}
// Apply the changes
newItems[index].percentage = Math.round(newPercentage * 10) / 10;
newItems[adjustIndex].percentage = Math.round(adjustedItemNewPercentage * 10) / 10;
// Ensure the total is exactly 100%
var total = newItems.reduce(function (sum, item) { return sum + item.percentage; }, 0);
if (Math.abs(total - 100) > 0.1) {
// Adjust the last item to make the total exactly 100%
var lastIndex = newItems.length - 1;
newItems[lastIndex].percentage += 100 - total;
newItems[lastIndex].percentage = Math.round(newItems[lastIndex].percentage * 10) / 10;
}
setItems(newItems);
setEditingPercentage(null);
};
// Handle key press in percentage input
var handlePercentageKeyDown = function (index, e) {
if (e.key === "Enter") {
applyPercentageChange(index);
}
};
// Reset to equal distribution
var resetDistribution = function () {
setItems(items.map(function (item) { return (__assign(__assign({}, item), { percentage: Math.round((100 / items.length) * 10) / 10 })); }));
};
// Add new item
var addItem = function () {
var newItemId = nextId.current;
nextId.current += 1;
// Calculate new percentage (equal distribution)
var newPercentage = 100 / (items.length + 1);
// Choose a color that's not already in use if possible
var usedColors = items.map(function (item) { return item.color; });
var newColor = COLORS[0];
for (var _i = 0, COLORS_1 = COLORS; _i < COLORS_1.length; _i++) {
var color = COLORS_1[_i];
if (!usedColors.includes(color)) {
newColor = color;
break;
}
}
// Create new item
var newItem = {
id: newItemId,
name: "Item ".concat(newItemId),
percentage: newPercentage,
color: newColor,
};
// Update all items with new percentages
var updatedItems = items.map(function (item) { return (__assign(__assign({}, item), { percentage: Math.round(newPercentage * 10) / 10 })); });
setItems(__spreadArray(__spreadArray([], updatedItems, true), [newItem], false));
};
// Delete item
var deleteItem = function (id) {
// Don't allow deleting if only 2 items remain
if (items.length <= 2) {
alert("You must have at least 2 items");
return;
}
// Get the percentage of the item to be deleted
var itemToDelete = items.find(function (item) { return item.id === id; });
if (!itemToDelete)
return;
var percentageToRedistribute = itemToDelete.percentage;
var remainingItems = items.filter(function (item) { return item.id !== id; });
// Redistribute the percentage equally among remaining items
var addPerItem = percentageToRedistribute / remainingItems.length;
setItems(remainingItems.map(function (item) { return (__assign(__assign({}, item), { percentage: Math.round((item.percentage + addPerItem) * 10) / 10 })); }));
};
// Color options for the dropdown
var colorOptions = [
"red",
"blue",
"green",
"yellow",
"purple",
"pink",
"indigo",
"orange",
"teal",
"cyan",
"lime",
"emerald",
"sky",
"violet",
"fuchsia",
"rose",
"amber",
];
return ((0, jsx_runtime_1.jsxs)("div", { className: "w-full max-w-4xl mx-auto p-4 md:p-6 space-y-6", children: [(0, jsx_runtime_1.jsx)("h1", { className: "text-xl md:text-2xl font-bold text-center", children: "Interactive Percentage Bar" }), (0, jsx_runtime_1.jsx)("div", { ref: barRef, className: "w-full h-12 md:h-16 flex rounded-md overflow-hidden border border-gray-300 relative", children: items.map(function (item, index) { return ((0, jsx_runtime_1.jsxs)("div", { className: (0, utils_1.cn)(item.color, "h-full flex items-center justify-center relative transition-all duration-75", index < items.length - 1 ? "cursor-ew-resize" : ""), style: { width: "".concat(item.percentage, "%") }, onMouseEnter: function () { return setHoveredIndex(index); }, onMouseLeave: function () { return setHoveredIndex(null); }, children: [(0, jsx_runtime_1.jsxs)("span", { className: "text-white font-medium text-xs md:text-sm", children: [item.percentage.toFixed(1), "%"] }), hoveredIndex === index && ((0, jsx_runtime_1.jsx)("div", { className: "absolute top-0 left-1/2 transform -translate-x-1/2 -translate-y-full bg-black text-white px-2 py-1 rounded text-xs whitespace-nowrap z-20", children: item.name })), index < items.length - 1 && ((0, jsx_runtime_1.jsx)("div", { className: "absolute right-0 top-0 w-1 md:w-2 h-full bg-black bg-opacity-20 cursor-ew-resize z-10 hover:bg-opacity-30", onMouseDown: function (e) { return handleMouseDown(index, e); } }))] }, item.id)); }) }), (0, jsx_runtime_1.jsx)("div", { className: "space-y-3 max-h-[50vh] overflow-y-auto pr-1", children: items.map(function (item, index) { return ((0, jsx_runtime_1.jsxs)("div", { className: "flex flex-wrap md:flex-nowrap items-center gap-2 md:gap-3 py-1", children: [(0, jsx_runtime_1.jsx)("div", { className: "flex items-center", children: (0, jsx_runtime_1.jsx)("select", { value: item.color.replace("bg-", "").replace("-500", ""), onChange: function (e) { return updateItemColor(item.id, e.target.value); }, className: "border border-gray-300 rounded h-8 text-sm px-1", style: {
backgroundColor: "var(--".concat(item.color.replace("bg-", "").replace("-500", ""), "-500)"),
color: "black",
}, children: colorOptions.map(function (color) { return ((0, jsx_runtime_1.jsx)("option", { value: color, children: color }, color)); }) }) }), (0, jsx_runtime_1.jsx)("input", { type: "text", value: item.name, onChange: function (e) { return updateItemName(item.id, e.target.value); }, className: "border border-gray-300 rounded px-3 py-1.5 flex-1 min-w-[120px]" }), (0, jsx_runtime_1.jsxs)("div", { className: "flex items-center", children: [(0, jsx_runtime_1.jsx)("input", { type: "text", value: (editingPercentage === null || editingPercentage === void 0 ? void 0 : editingPercentage.index) === index ? editingPercentage.value : item.percentage.toFixed(1), onChange: function (e) { return handlePercentageChange(index, e.target.value); }, onBlur: function () { return applyPercentageChange(index); }, onKeyDown: function (e) { return handlePercentageKeyDown(index, e); }, className: "border border-gray-300 rounded px-3 py-1.5 w-16 md:w-20 text-right" }), (0, jsx_runtime_1.jsx)("span", { className: "text-sm font-medium ml-1", children: "%" })] }), (0, jsx_runtime_1.jsx)("button", { onClick: function () { return deleteItem(item.id); }, className: "p-1.5 text-red-500 hover:text-red-700 transition-colors", title: "Delete item", children: (0, jsx_runtime_1.jsx)(lucide_react_1.Trash2, { size: 18 }) })] }, item.id)); }) }), (0, jsx_runtime_1.jsxs)("div", { className: "flex flex-wrap gap-3", children: [(0, jsx_runtime_1.jsx)("button", { onClick: resetDistribution, className: "px-4 py-2 bg-gray-200 rounded hover:bg-gray-300 transition-colors", children: "Reset to Equal Distribution" }), (0, jsx_runtime_1.jsxs)("button", { onClick: addItem, className: "px-4 py-2 bg-green-500 text-white rounded hover:bg-green-600 transition-colors flex items-center gap-1", children: [(0, jsx_runtime_1.jsx)(lucide_react_1.PlusCircle, { size: 18 }), (0, jsx_runtime_1.jsx)("span", { children: "Add Item" })] })] }), (0, jsx_runtime_1.jsxs)("div", { className: "text-sm text-gray-500 space-y-1", children: [(0, jsx_runtime_1.jsx)("p", { children: "Drag the dividers between sections to adjust percentages or input values directly." }), (0, jsx_runtime_1.jsx)("p", { children: "When you change a percentage, it affects the item to the right (or the second-to-last item if editing the last one)." }), (0, jsx_runtime_1.jsx)("p", { children: "Minimum value for any section is 2%." })] }), (0, jsx_runtime_1.jsxs)("div", { className: "mt-8 pt-4 border-t border-gray-200 text-center text-sm text-gray-500 flex flex-wrap items-center justify-center gap-2", children: [(0, jsx_runtime_1.jsx)("span", { children: "Developed by Zaman Sheikh" }), (0, jsx_runtime_1.jsxs)("a", { href: "https://github.com/zamansheikh", target: "_blank", rel: "noopener noreferrer", className: "flex items-center text-gray-700 hover:text-black transition-colors", children: [(0, jsx_runtime_1.jsx)(lucide_react_1.Github, { size: 16, className: "mr-1" }), "github.com/zamansheikh"] })] })] }));
}