react-color-palette
Version:
🎨 Lightweight Color Picker component for React.
484 lines (465 loc) • 17.5 kB
JavaScript
"use client"
// src/components/color-picker/color-picker.component.tsx
import React6, { memo as memo6 } from "react";
// src/utils/is-field-hide/is-field-hide.util.ts
function isFieldHide(hideInput, field) {
return Array.isArray(hideInput) ? hideInput.includes(field) : hideInput;
}
// src/components/alpha/alpha.component.tsx
import React2, { memo as memo2, useCallback as useCallback3, useMemo } from "react";
// src/hooks/use-bounding-client-rect/use-bounding-client-rect.hook.ts
import { useCallback, useLayoutEffect, useRef, useState } from "react";
var getElementDimensions = (element) => {
const rect = element.getBoundingClientRect();
return {
width: rect.width,
height: rect.height
};
};
function useBoundingClientRect() {
const ref = useRef(null);
const [size, setSize] = useState({ width: 1, height: 1 });
useLayoutEffect(() => {
const onWindowResize = () => {
if (!ref.current)
return;
setSize(getElementDimensions(ref.current));
};
const onElementResize = ([{ contentBoxSize }]) => {
setSize({
height: contentBoxSize[0].blockSize,
width: contentBoxSize[0].inlineSize
});
};
window.addEventListener("resize", onWindowResize, false);
const observer = new ResizeObserver(onElementResize);
if (ref.current)
observer.observe(ref.current);
return () => {
window.removeEventListener("resize", onWindowResize, false);
observer.disconnect();
};
}, []);
const getPosition = useCallback(() => {
const { left = 1, right = 1, top = 1, bottom = 1 } = ref.current?.getBoundingClientRect() ?? {};
return { left, right, top, bottom };
}, []);
return [ref, size, getPosition];
}
// src/utils/clamp/clamp.util.ts
function clamp(value, min, max) {
return value < min ? min : value > max ? max : value;
}
// src/services/color/color.service.ts
var ColorServiceStatic = class {
convert(model, color) {
let hex = this.toHex("#000000");
let rgb = this.hex2rgb(hex);
let hsv = this.rgb2hsv(rgb);
if (model === "hex") {
const value = color;
hex = this.toHex(value);
rgb = this.hex2rgb(hex);
if (hex.startsWith("rgba")) {
rgb = this.toRgb(hex);
hex = this.rgb2hex(rgb);
}
hsv = this.rgb2hsv(rgb);
} else if (model === "rgb") {
const value = color;
rgb = value;
hex = this.rgb2hex(rgb);
hsv = this.rgb2hsv(rgb);
} else if (model === "hsv") {
const value = color;
hsv = value;
rgb = this.hsv2rgb(hsv);
hex = this.rgb2hex(rgb);
}
return { hex, rgb, hsv };
}
toHex(value) {
if (!value.startsWith("#")) {
const ctx = document.createElement("canvas").getContext("2d");
if (!ctx)
throw new Error("2d context not supported or canvas already initialized");
ctx.fillStyle = value;
return ctx.fillStyle;
} else if (value.length === 4 || value.length === 5) {
value = value.split("").map((v, i) => i ? i < 4 ? v + v : v === "f" ? void 0 : v + v : "#").join("");
return value;
} else if (value.length === 7) {
return value;
} else if (value.length === 9) {
return value.endsWith("ff") ? value.slice(0, 7) : value;
}
return "#000000";
}
toRgb(value) {
const rgb = value.match(/\d+(\.\d+)?/gu) ?? [];
const [r, g, b, a] = Array.from({ length: 4 }).map(
(_, i) => clamp(+(rgb[i] ?? (i < 3 ? 0 : 1)), 0, i < 3 ? 255 : 1)
);
return { r, g, b, a };
}
toHsv(value) {
const hsv = value.match(/\d+(\.\d+)?/gu) ?? [];
const [h, s, v, a] = Array.from({ length: 4 }).map(
(_, i) => clamp(+(hsv[i] ?? (i < 3 ? 0 : 1)), 0, i ? i < 3 ? 100 : 1 : 360)
);
return { h, s, v, a };
}
hex2rgb(hex) {
hex = hex.slice(1);
let [r, g, b, a] = Array.from({ length: 4 }).map((_, i) => parseInt(hex.slice(i * 2, i * 2 + 2), 16));
a = Number.isNaN(a) ? 1 : a / 255;
return { r, g, b, a };
}
rgb2hsv({ r, g, b, a }) {
r /= 255;
g /= 255;
b /= 255;
const max = Math.max(r, g, b);
const d = max - Math.min(r, g, b);
const h = d ? (max === r ? (g - b) / d + (g < b ? 6 : 0) : max === g ? 2 + (b - r) / d : 4 + (r - g) / d) * 60 : 0;
const s = max ? d / max * 100 : 0;
const v = max * 100;
return { h, s, v, a };
}
hsv2rgb({ h, s, v, a }) {
s /= 100;
v /= 100;
const i = ~~(h / 60);
const f = h / 60 - i;
const p = v * (1 - s);
const q = v * (1 - s * f);
const t = v * (1 - s * (1 - f));
const index = i % 6;
const r = [v, q, p, p, t, v][index] * 255;
const g = [t, v, v, q, p, p][index] * 255;
const b = [p, p, t, v, v, q][index] * 255;
return { r, g, b, a };
}
rgb2hex({ r, g, b, a }) {
const [rr, gg, bb, aa] = [r, g, b, a].map(
(v, i) => Math.round(i < 3 ? v : v * 255).toString(16).padStart(2, "0")
);
return ["#", rr, gg, bb, aa === "ff" ? "" : aa].join("");
}
};
var ColorService = new ColorServiceStatic();
// src/components/interactive/interactive.component.tsx
import React, { memo, useCallback as useCallback2 } from "react";
// src/utils/is-touch/is-touch.util.ts
function isTouch(event) {
return "touches" in event;
}
// src/components/interactive/interactive.component.tsx
var Interactive = memo(({ onCoordinateChange, children, disabled }) => {
const [interactiveRef, { width, height }, getPosition] = useBoundingClientRect();
const move = useCallback2(
(event, final = false) => {
const { left, top } = getPosition();
const x = clamp(event.clientX - left, 0, width);
const y = clamp(event.clientY - top, 0, height);
onCoordinateChange(final, x, y);
},
[width, height, getPosition, onCoordinateChange]
);
const onStart = useCallback2(
(event) => {
if (!isTouch(event) && event.button !== 0)
return;
const onMove = (event2) => {
move(isTouch(event2) ? event2.touches[0] : event2);
};
const onEnd = (event2) => {
move(isTouch(event2) ? event2.changedTouches[0] : event2, true);
document.removeEventListener(isTouch(event2) ? "touchmove" : "mousemove", onMove, false);
document.removeEventListener(isTouch(event2) ? "touchend" : "mouseup", onEnd, false);
};
onMove(event);
document.addEventListener(isTouch(event) ? "touchmove" : "mousemove", onMove, false);
document.addEventListener(isTouch(event) ? "touchend" : "mouseup", onEnd, false);
},
[move]
);
return /* @__PURE__ */ React.createElement(
"div",
{
ref: interactiveRef,
className: "rcp-interactive",
onMouseDown: onStart,
onTouchStart: onStart,
"aria-disabled": disabled
},
children
);
});
// src/components/alpha/alpha.component.tsx
var Alpha = memo2(({ color, disabled, onChange, onChangeComplete }) => {
const [alphaRef, { width }] = useBoundingClientRect();
const position = useMemo(() => {
const x = color.hsv.a * width;
return { x };
}, [color.hsv.a, width]);
const updateColor = useCallback3(
(final, x) => {
const nextColor = ColorService.convert("hsv", {
...color.hsv,
a: x / width
});
onChange(nextColor);
if (final)
onChangeComplete?.(nextColor);
},
[color.hsv, width, onChange, onChangeComplete]
);
const rgb = useMemo(() => [color.rgb.r, color.rgb.g, color.rgb.b].join(" "), [color.rgb.r, color.rgb.g, color.rgb.b]);
const rgba = useMemo(() => [rgb, color.rgb.a].join(" / "), [rgb, color.rgb.a]);
return /* @__PURE__ */ React2.createElement(Interactive, { disabled, onCoordinateChange: updateColor }, /* @__PURE__ */ React2.createElement(
"div",
{
ref: alphaRef,
style: {
background: `linear-gradient(to right, rgb(${rgb} / 0), rgb(${rgb} / 1)) top left / auto auto,
conic-gradient(#666 0.25turn, #999 0.25turn 0.5turn, #666 0.5turn 0.75turn, #999 0.75turn) top left / 12px 12px
repeat`
},
className: "rcp-alpha"
},
/* @__PURE__ */ React2.createElement(
"div",
{
style: {
left: position.x,
background: `linear-gradient(to right, rgb(${rgba}), rgb(${rgba})) top left / auto auto,
conic-gradient(#666 0.25turn, #999 0.25turn 0.5turn, #666 0.5turn 0.75turn, #999 0.75turn) ${-position.x - 4}px 2px / 12px 12px
repeat`
},
className: "rcp-alpha-cursor"
}
)
));
});
// src/components/fields/fields.component.tsx
import React3, { memo as memo3, useCallback as useCallback4, useEffect, useState as useState2 } from "react";
// src/utils/float/float.util.ts
function float(value, decimalPlaces) {
return Math.round(value * 10 ** decimalPlaces) / 10 ** decimalPlaces;
}
// src/utils/format/format.util.ts
function formatRgb({ r, g, b, a }) {
const rgb = [Math.round(r), Math.round(g), Math.round(b)];
const alpha = float(a, 3);
if (alpha < 1)
rgb.push(alpha);
return rgb.join(", ");
}
function formatHsv({ h, s, v, a }) {
const hsv = [`${Math.round(h)}\xB0`, `${Math.round(s)}%`, `${Math.round(v)}%`];
const alpha = float(a, 3);
if (alpha < 1)
hsv.push(alpha);
return hsv.join(", ");
}
// src/components/fields/fields.component.tsx
var Fields = memo3(({ hideInput, color, disabled, onChange, onChangeComplete }) => {
const [fields, setFields] = useState2({
hex: {
value: color.hex,
inputted: false
},
rgb: {
value: formatRgb(color.rgb),
inputted: false
},
hsv: {
value: formatHsv(color.hsv),
inputted: false
}
});
useEffect(() => {
if (!fields.hex.inputted) {
setFields((fields2) => ({ ...fields2, hex: { ...fields2.hex, value: color.hex } }));
}
}, [fields.hex.inputted, color.hex]);
useEffect(() => {
if (!fields.rgb.inputted) {
setFields((fields2) => ({ ...fields2, rgb: { ...fields2.rgb, value: formatRgb(color.rgb) } }));
}
}, [fields.rgb.inputted, color.rgb]);
useEffect(() => {
if (!fields.hsv.inputted) {
setFields((fields2) => ({ ...fields2, hsv: { ...fields2.hsv, value: formatHsv(color.hsv) } }));
}
}, [fields.hsv.inputted, color.hsv]);
const onInputChange = useCallback4(
(field) => (event) => {
const { value } = event.target;
setFields((fields2) => ({ ...fields2, [field]: { ...fields2[field], value } }));
if (field === "hsv")
onChange(ColorService.convert("hsv", ColorService.toHsv(value)));
else if (field === "rgb")
onChange(ColorService.convert("rgb", ColorService.toRgb(value)));
else
onChange(ColorService.convert("hex", value));
},
[onChange]
);
const onInputFocus = useCallback4(
(field) => () => {
setFields((fields2) => ({ ...fields2, [field]: { ...fields2[field], inputted: true } }));
},
[]
);
const onInputBlur = useCallback4(
(field) => (event) => {
const { value } = event.target;
setFields((fields2) => ({ ...fields2, [field]: { ...fields2[field], inputted: false } }));
if (field === "hsv")
onChangeComplete?.(ColorService.convert("hsv", ColorService.toHsv(value)));
else if (field === "rgb")
onChangeComplete?.(ColorService.convert("rgb", ColorService.toRgb(value)));
else
onChangeComplete?.(ColorService.convert("hex", value));
},
[onChangeComplete]
);
return /* @__PURE__ */ React3.createElement("div", { className: "rcp-fields" }, !isFieldHide(hideInput, "hex") && /* @__PURE__ */ React3.createElement("div", { className: "rcp-fields-floor" }, /* @__PURE__ */ React3.createElement("div", { className: "rcp-field" }, /* @__PURE__ */ React3.createElement(
"input",
{
id: "hex",
className: "rcp-field-input",
readOnly: disabled,
value: fields.hex.value,
onChange: onInputChange("hex"),
onFocus: onInputFocus("hex"),
onBlur: onInputBlur("hex")
}
), /* @__PURE__ */ React3.createElement("label", { htmlFor: "hex", className: "rcp-field-label" }, "HEX"))), (!isFieldHide(hideInput, "rgb") || !isFieldHide(hideInput, "hsv")) && /* @__PURE__ */ React3.createElement("div", { className: "rcp-fields-floor" }, !isFieldHide(hideInput, "rgb") && /* @__PURE__ */ React3.createElement("div", { className: "rcp-field" }, /* @__PURE__ */ React3.createElement(
"input",
{
id: "rgb",
className: "rcp-field-input",
readOnly: disabled,
value: fields.rgb.value,
onChange: onInputChange("rgb"),
onFocus: onInputFocus("rgb"),
onBlur: onInputBlur("rgb")
}
), /* @__PURE__ */ React3.createElement("label", { htmlFor: "rgb", className: "rcp-field-label" }, "RGB")), !isFieldHide(hideInput, "hsv") && /* @__PURE__ */ React3.createElement("div", { className: "rcp-field" }, /* @__PURE__ */ React3.createElement(
"input",
{
id: "hsv",
className: "rcp-field-input",
readOnly: disabled,
value: fields.hsv.value,
onChange: onInputChange("hsv"),
onFocus: onInputFocus("hsv"),
onBlur: onInputBlur("hsv")
}
), /* @__PURE__ */ React3.createElement("label", { htmlFor: "hsv", className: "rcp-field-label" }, "HSV"))));
});
// src/components/hue/hue.component.tsx
import React4, { memo as memo4, useCallback as useCallback5, useMemo as useMemo2 } from "react";
var Hue = memo4(({ color, disabled, onChange, onChangeComplete }) => {
const [hueRef, { width }] = useBoundingClientRect();
const position = useMemo2(() => {
const x = color.hsv.h / 360 * width;
return { x };
}, [color.hsv.h, width]);
const updateColor = useCallback5(
(final, x) => {
const nextColor = ColorService.convert("hsv", {
...color.hsv,
h: x / width * 360
});
onChange(nextColor);
if (final)
onChangeComplete?.(nextColor);
},
[color.hsv, width, onChange, onChangeComplete]
);
const hsl = useMemo2(() => [color.hsv.h, "100%", "50%"].join(" "), [color.hsv.h]);
return /* @__PURE__ */ React4.createElement(Interactive, { disabled, onCoordinateChange: updateColor }, /* @__PURE__ */ React4.createElement("div", { ref: hueRef, className: "rcp-hue" }, /* @__PURE__ */ React4.createElement("div", { style: { left: position.x, backgroundColor: `hsl(${hsl})` }, className: "rcp-hue-cursor" })));
});
// src/components/saturation/saturation.component.tsx
import React5, { memo as memo5, useCallback as useCallback6, useMemo as useMemo3 } from "react";
var Saturation = memo5(({ height, color, disabled, onChange, onChangeComplete }) => {
const [saturationRef, { width }] = useBoundingClientRect();
const position = useMemo3(() => {
const x = color.hsv.s / 100 * width;
const y = (100 - color.hsv.v) / 100 * height;
return { x, y };
}, [color.hsv.s, color.hsv.v, width, height]);
const updateColor = useCallback6(
(final, x, y) => {
const nextColor = ColorService.convert("hsv", {
...color.hsv,
s: x / width * 100,
v: 100 - y / height * 100
});
onChange(nextColor);
if (final)
onChangeComplete?.(nextColor);
},
[color.hsv, width, height, onChange, onChangeComplete]
);
const hsl = useMemo3(() => [color.hsv.h, "100%", "50%"].join(" "), [color.hsv.h]);
const rgb = useMemo3(() => [color.rgb.r, color.rgb.g, color.rgb.b].join(" "), [color.rgb.r, color.rgb.g, color.rgb.b]);
return /* @__PURE__ */ React5.createElement(Interactive, { disabled, onCoordinateChange: updateColor }, /* @__PURE__ */ React5.createElement("div", { ref: saturationRef, style: { height, backgroundColor: `hsl(${hsl})` }, className: "rcp-saturation" }, /* @__PURE__ */ React5.createElement(
"div",
{
style: { left: position.x, top: position.y, backgroundColor: `rgb(${rgb})` },
className: "rcp-saturation-cursor"
}
)));
});
// src/components/color-picker/color-picker.component.tsx
var ColorPicker = memo6(
({
height = 200,
hideAlpha = false,
hideInput = false,
color,
disabled = false,
onChange,
onChangeComplete
}) => /* @__PURE__ */ React6.createElement("div", { className: "rcp-root rcp" }, /* @__PURE__ */ React6.createElement(
Saturation,
{
height,
color,
disabled,
onChange,
onChangeComplete
}
), /* @__PURE__ */ React6.createElement("div", { className: "rcp-body" }, /* @__PURE__ */ React6.createElement("section", { className: "rcp-section" }, /* @__PURE__ */ React6.createElement(Hue, { color, disabled, onChange, onChangeComplete }), !hideAlpha && /* @__PURE__ */ React6.createElement(Alpha, { color, disabled, onChange, onChangeComplete })), (!isFieldHide(hideInput, "hex") || !isFieldHide(hideInput, "rgb") || !isFieldHide(hideInput, "hsv")) && /* @__PURE__ */ React6.createElement("section", { className: "rcp-section" }, /* @__PURE__ */ React6.createElement(
Fields,
{
hideInput,
color,
disabled,
onChange,
onChangeComplete
}
))))
);
// src/hooks/use-color/use-color.hook.ts
import { useEffect as useEffect2, useState as useState3 } from "react";
function useColor(initialColor) {
const [color, setColor] = useState3(ColorService.convert("hex", initialColor));
useEffect2(() => {
setColor(ColorService.convert("hex", initialColor));
}, [initialColor]);
return [color, setColor];
}
export {
Alpha,
ColorPicker,
ColorService,
Hue,
Saturation,
useColor
};