diff --git a/playground/src/App.tsx b/playground/src/App.tsx index 3c08baa..d4f8bf3 100644 --- a/playground/src/App.tsx +++ b/playground/src/App.tsx @@ -4,8 +4,16 @@ import Flex from "../../src/components/flex/Flex"; import CopyButton from "../../src/components/copy-button/CopyButton"; import ToastStack from "../../src/components/toastcontainer/ToastStack"; import { toastStore } from "../../src/stores/toastStore"; +import { createSignal } from "solid-js"; +import ColorPicker from "@src/components/colorpicker"; export default function App() { + const [color1, setColor1] = createSignal("#4ECDC4"); + const [color2, setColor2] = createSignal("rgb(255, 107, 107)"); + const [color3, setColor3] = createSignal("rgba(69, 183, 209, 0.7)"); + const [color4, setColor4] = createSignal("hsl(45, 100%, 60%)"); + const [color5, setColor5] = createSignal("#98D8C8"); + return ( App.tsx to try out any component from @pathscale/ui.

- + hiohoho @@ -55,10 +60,7 @@ export default function App() { - @@ -71,6 +73,257 @@ export default function App() {
+
+
+

+ ColorPicker Component +

+

+ A comprehensive color picker with support for multiple formats, + alpha channel, swatches, and full keyboard navigation. +

+
+ +
+

Basic Usage

+
+ setColor1(c)} + format="hex" + /> +
+

+ Selected Color (HEX): +

+

{color1()}

+
+
+
+ +
+

+ Multiple Color Formats +

+
+
+ +
+

HEX

+

{color1()}

+
+
+
+ +
+

RGB

+

{color2()}

+
+
+
+ +
+

RGBA

+

{color3()}

+
+
+
+ +
+

HSL

+

{color4()}

+
+
+
+
+ +
+

+ With Alpha/Opacity Control +

+
+ +
+

+ Selected Color (RGBA): +

+

{color3()}

+
+
+
+
+
+
+ +
+

Custom Swatches

+
+ +
+

+ Custom Color Palette: +

+

{color5()}

+
+
+
+ +
+

Color Wheel Mode

+
+ +
+

+ Starts in Wheel Mode: +

+

{color4()}

+
+
+
+ +
+

+ Popover Placement Options +

+
+
+ +

Bottom (default)

+
+
+ +

Top

+
+
+ +

Left

+
+
+ +

Right

+
+
+
+ +
+

Disabled State

+
+ +

+ This picker is disabled and cannot be opened. +

+
+
+ +
+

Keyboard Navigation

+
+

Try these keyboard shortcuts:

+
    +
  • + + Tab + {" "} + - Navigate between elements +
  • +
  • + + Arrow Keys + {" "} + - Adjust sliders and saturation/brightness +
  • +
  • + + Enter + {" "} + - Select swatches +
  • +
  • + + Escape + {" "} + - Close popover +
  • +
+
+ +
+
+
+ +
+

API Reference

+
+
+              {` void
+  format?: ColorFormat            // 'hex' | 'rgb' | 'rgba' | 'hsl' | 'hsla'
+  disabled?: boolean
+  swatches?: string[]
+  showAlpha?: boolean
+  placement?: 'top' | 'bottom' | 'left' | 'right'
+/>`}
+            
+
+
+
); } diff --git a/src/components/colorpicker/AlphaSlider.tsx b/src/components/colorpicker/AlphaSlider.tsx new file mode 100644 index 0000000..5a77c41 --- /dev/null +++ b/src/components/colorpicker/AlphaSlider.tsx @@ -0,0 +1,140 @@ +import { type JSX, createSignal, onMount, onCleanup } from "solid-js"; +import { clsx } from "clsx"; +import { twMerge } from "tailwind-merge"; +import { useColorPickerContext } from "./colorpickerContext"; +import { setAlpha } from "./ColorUtils"; + +export interface AlphaSliderProps { + class?: string; + className?: string; +} + +const AlphaSlider = (props: AlphaSliderProps): JSX.Element => { + const context = useColorPickerContext(); + const [isDragging, setIsDragging] = createSignal(false); + let sliderRef: HTMLDivElement | undefined; + + const alpha = () => context.color().hsl.a; + + const updateAlpha = (clientX: number) => { + if (!sliderRef || context.disabled()) return; + + const rect = sliderRef.getBoundingClientRect(); + const percentage = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)); + const newAlpha = Math.round(percentage * 100) / 100; + + const newColor = setAlpha(context.color(), newAlpha); + context.onChange(newColor); + }; + + const handleMouseDown = (e: MouseEvent) => { + if (context.disabled()) return; + setIsDragging(true); + updateAlpha(e.clientX); + e.preventDefault(); + }; + + const handleMouseMove = (e: MouseEvent) => { + if (isDragging()) { + updateAlpha(e.clientX); + e.preventDefault(); + } + }; + + const handleMouseUp = () => { + setIsDragging(false); + }; + + const handleKeyDown = (e: KeyboardEvent) => { + if (context.disabled()) return; + + const step = e.shiftKey ? 0.1 : 0.01; + let newAlpha = alpha(); + + switch (e.key) { + case "ArrowLeft": + case "ArrowDown": + newAlpha = Math.max(0, alpha() - step); + e.preventDefault(); + break; + case "ArrowRight": + case "ArrowUp": + newAlpha = Math.min(1, alpha() + step); + e.preventDefault(); + break; + default: + return; + } + + const newColor = setAlpha(context.color(), newAlpha); + context.onChange(newColor); + }; + + onMount(() => { + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("mouseup", handleMouseUp); + }); + + onCleanup(() => { + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + }); + + const classes = () => + twMerge( + "relative w-full h-6 rounded cursor-pointer select-none overflow-hidden", + clsx({ + "opacity-50 cursor-not-allowed": context.disabled(), + }), + props.class, + props.className, + ); + + const thumbClasses = () => + twMerge( + "absolute top-1/2 w-4 h-4 border-2 border-white rounded-full shadow-lg transform -translate-x-1/2 -translate-y-1/2 pointer-events-none", + clsx({ + "ring-2 ring-primary": isDragging(), + }), + ); + + return ( +
+
+
+
+
+ ); +}; + +export default AlphaSlider; diff --git a/src/components/colorpicker/ColorInput.tsx b/src/components/colorpicker/ColorInput.tsx new file mode 100644 index 0000000..79be22a --- /dev/null +++ b/src/components/colorpicker/ColorInput.tsx @@ -0,0 +1,115 @@ +import { type JSX, createSignal, createEffect } from "solid-js"; +import { clsx } from "clsx"; +import { twMerge } from "tailwind-merge"; +import { useColorPickerContext } from "./colorpickerContext"; +import type { ColorFormat } from "./ColorUtils"; +import { parseColor, formatColor } from "./ColorUtils"; + +export interface ColorInputProps { + class?: string; + className?: string; +} + +const ColorInput = (props: ColorInputProps): JSX.Element => { + const context = useColorPickerContext(); + const [inputValue, setInputValue] = createSignal(""); + const [isValid, setIsValid] = createSignal(true); + + createEffect(() => { + const formatted = formatColor(context.color(), context.format()); + setInputValue(formatted); + setIsValid(true); + }); + + const handleInput = (e: Event) => { + const target = e.target as HTMLInputElement; + const value = target.value; + setInputValue(value); + + const parsed = parseColor(value); + if (parsed) { + setIsValid(true); + } else { + setIsValid(false); + } + }; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Enter") { + applyColor(); + } + }; + + const handleBlur = () => { + applyColor(); + }; + + const applyColor = () => { + const parsed = parseColor(inputValue()); + if (parsed) { + context.onChange(parsed); + setIsValid(true); + } else { + // Reset to current color if invalid + const formatted = formatColor(context.color(), context.format()); + setInputValue(formatted); + setIsValid(true); + } + }; + + const handleFormatChange = (e: Event) => { + const target = e.target as HTMLSelectElement; + const newFormat = target.value as ColorFormat; + context.onFormatChange(newFormat); + }; + + const inputClasses = () => + twMerge( + "flex-1 px-3 py-2 text-sm border rounded-l focus:outline-none focus:ring-2 focus:ring-primary", + clsx({ + "border-error focus:ring-error": !isValid(), + "border-base-300": isValid(), + "bg-base-200 cursor-not-allowed": context.disabled(), + "bg-base-100 text-base-content": !context.disabled(), + }), + ); + + const selectClasses = () => + twMerge( + "px-3 py-2 text-sm border border-l-0 rounded-r focus:outline-none focus:ring-2 focus:ring-primary bg-base-100 text-base-content border-base-300", + clsx({ + "bg-base-200 cursor-not-allowed": context.disabled(), + }), + ); + + return ( +
+ + +
+ ); +}; + +export default ColorInput; diff --git a/src/components/colorpicker/ColorPicker.tsx b/src/components/colorpicker/ColorPicker.tsx new file mode 100644 index 0000000..0179419 --- /dev/null +++ b/src/components/colorpicker/ColorPicker.tsx @@ -0,0 +1,264 @@ +import { + type JSX, + Show, + createSignal, + createMemo, + splitProps, +} from "solid-js"; +import { clsx } from "clsx"; +import { twMerge } from "tailwind-merge"; +import { ColorPickerContext } from "./colorpickerContext"; +import type { ColorFormat, ColorValue } from "./ColorUtils"; +import { parseColor, getDefaultColor, formatColor } from "./ColorUtils"; +import SaturationBrightness from "./SaturationBrightness"; +import HueSlider from "./HueSlider"; +import AlphaSlider from "./AlphaSlider"; +import ColorWheel from "./ColorWheel"; +import LightnessSlider from "./LightnessSlider"; +import ColorSwatches from "./ColorSwatches"; +import ColorInput from "./ColorInput"; +import ColorPreview from "./ColorPreview"; +import { IComponentBaseProps } from "../types"; +import { MotionDiv, motionPresets } from "../../motion"; + +export type { ColorFormat } from "./ColorUtils"; +export type ColorPickerMode = "picker" | "wheel"; + +export interface ColorPickerProps extends IComponentBaseProps { + value?: string; + onChange?: (color: string) => void; + format?: ColorFormat; + disabled?: boolean; + swatches?: string[]; + showAlpha?: boolean; + placement?: "top" | "bottom" | "left" | "right"; + initialMode?: ColorPickerMode; + "aria-label"?: string; +} + +const DEFAULT_SWATCHES = [ + "#FF6B6B", + "#4ECDC4", + "#45B7D1", + "#FFA07A", + "#98D8C8", + "#F7DC6F", + "#BB8FCE", + "#85929E", + "#FF5733", + "#C70039", + "#900C3F", + "#581845", + "#2ECC71", + "#3498DB", + "#9B59B6", + "#E74C3C", +]; + +const ColorPicker = (props: ColorPickerProps): JSX.Element => { + const [local, others] = splitProps(props, [ + "value", + "onChange", + "format", + "disabled", + "swatches", + "showAlpha", + "placement", + "initialMode", + "class", + "className", + "dataTheme", + "style", + "aria-label", + ]); + + const [isOpen, setIsOpen] = createSignal(false); + const [internalColor, setInternalColor] = createSignal( + getDefaultColor() + ); + const [currentFormat, setCurrentFormat] = createSignal( + local.format || "hex" + ); + const [mode, setMode] = createSignal( + local.initialMode || "picker" + ); + + let containerRef: HTMLDivElement | undefined; + + const color = createMemo(() => { + if (local.value) { + const parsed = parseColor(local.value); + if (parsed) { + setInternalColor(parsed); + return parsed; + } + } + return internalColor(); + }); + + const handleColorChange = (newColor: ColorValue) => { + setInternalColor(newColor); + if (local.onChange) { + const formatted = formatColor(newColor, currentFormat()); + local.onChange(formatted); + } + }; + + const handleFormatChange = (newFormat: ColorFormat) => { + setCurrentFormat(newFormat); + if (local.onChange) { + const formatted = formatColor(color(), newFormat); + local.onChange(formatted); + } + }; + + const togglePicker = () => { + if (!local.disabled) { + setIsOpen(!isOpen()); + } + }; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape" && isOpen()) { + setIsOpen(false); + e.preventDefault(); + } + }; + + + const contextValue = { + color, + format: currentFormat, + disabled: () => local.disabled || false, + onChange: handleColorChange, + onFormatChange: handleFormatChange, + }; + + const containerClasses = () => + twMerge( + "relative inline-block", + clsx({ + "opacity-50": local.disabled, + }), + local.class, + local.className + ); + + const popoverClasses = () => { + const base = + "z-50 p-4 bg-base-100 rounded-lg shadow-xl border border-base-300 min-w-[280px]"; + + + return twMerge( + base, + "absolute mt-2", + clsx({ + "bottom-full mb-2 mt-0": local.placement === "top", + "top-full mt-2": local.placement === "bottom" || !local.placement, + "right-full mr-2": local.placement === "left", + "left-full ml-2": local.placement === "right", + }) + ); + }; + + const ModeSwitcher = () => ( +
+ + +
+ ); + + const PopoverContent = () => ( + + + +
+ + + + + + + + + + + + + + + + + +
+ +
+
+
+
+ ); + + return ( + +
+ + + + + + + +
setIsOpen(false)} + aria-hidden="true" + /> + +
+ + ); +}; + +export default ColorPicker; diff --git a/src/components/colorpicker/ColorPreview.tsx b/src/components/colorpicker/ColorPreview.tsx new file mode 100644 index 0000000..462d180 --- /dev/null +++ b/src/components/colorpicker/ColorPreview.tsx @@ -0,0 +1,60 @@ +import type { JSX } from "solid-js"; +import { clsx } from "clsx"; +import { twMerge } from "tailwind-merge"; +import { formatColor } from "./ColorUtils"; +import type { ColorValue } from "./ColorUtils"; + +export interface ColorPreviewProps { + color: ColorValue; + disabled?: boolean; + onClick?: () => void; + class?: string; + className?: string; +} + +const ColorPreview = (props: ColorPreviewProps): JSX.Element => { + const classes = () => + twMerge( + "w-10 h-10 rounded border-2 cursor-pointer transition-all duration-150 overflow-hidden", + clsx({ + "border-gray-300 hover:scale-110 hover:shadow-lg dark:border-gray-600": + !props.disabled, + "border-gray-200 opacity-50 cursor-not-allowed": props.disabled, + }), + props.class, + props.className, + ); + + const hasAlpha = () => props.color.hsl.a < 1; + + return ( + + ); +}; + +export default ColorPreview; diff --git a/src/components/colorpicker/ColorSwatches.tsx b/src/components/colorpicker/ColorSwatches.tsx new file mode 100644 index 0000000..de7d1a1 --- /dev/null +++ b/src/components/colorpicker/ColorSwatches.tsx @@ -0,0 +1,74 @@ +import { type JSX, For } from "solid-js"; +import { clsx } from "clsx"; +import { twMerge } from "tailwind-merge"; +import { useColorPickerContext } from "./colorpickerContext"; +import { parseColor } from "./ColorUtils"; + +export interface ColorSwatchesProps { + swatches: string[]; + class?: string; + className?: string; +} + +const ColorSwatches = (props: ColorSwatchesProps): JSX.Element => { + const context = useColorPickerContext(); + + const handleSwatchClick = (swatch: string) => { + if (context.disabled()) return; + const color = parseColor(swatch); + if (color) { + context.onChange(color); + } + }; + + const handleKeyDown = (e: KeyboardEvent, swatch: string) => { + if (context.disabled()) return; + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + handleSwatchClick(swatch); + } + }; + + const classes = () => + twMerge( + "grid grid-cols-8 gap-2 w-full", + clsx({ + "opacity-50 pointer-events-none": context.disabled(), + }), + props.class, + props.className, + ); + + const swatchClasses = (swatch: string) => { + const isSelected = context.color().hex.toLowerCase() === swatch.toLowerCase(); + return twMerge( + "w-8 h-8 rounded cursor-pointer border-2 transition-all duration-150", + clsx({ + "border-primary ring-2 ring-primary ring-offset-2": isSelected, + "border-gray-300 hover:scale-110 hover:shadow-lg": !isSelected, + "dark:border-gray-600": !isSelected, + }), + ); + }; + + return ( +
+ + {(swatch) => ( +
+ ); +}; + +export default ColorSwatches; diff --git a/src/components/colorpicker/ColorUtils.ts b/src/components/colorpicker/ColorUtils.ts new file mode 100644 index 0000000..56da498 --- /dev/null +++ b/src/components/colorpicker/ColorUtils.ts @@ -0,0 +1,249 @@ + +export type ColorFormat = "hex" | "rgb" | "rgba" | "hsl" | "hsla"; + +export interface RGB { + r: number; // 0-255 + g: number; // 0-255 + b: number; // 0-255 +} + +export interface RGBA extends RGB { + a: number; // 0-1 +} + +export interface HSL { + h: number; // 0-360 + s: number; // 0-100 + l: number; // 0-100 +} + +export interface HSLA extends HSL { + a: number; // 0-1 +} + +export interface ColorValue { + rgb: RGBA; + hsl: HSLA; + hex: string; +} + +/** + * Convert hex color to RGB + */ +export function hexToRgb(hex: string): RGB | null { + const cleaned = hex.replace(/^#/, ""); + + if (cleaned.length === 3) { + const r = Number.parseInt(cleaned[0] + cleaned[0], 16); + const g = Number.parseInt(cleaned[1] + cleaned[1], 16); + const b = Number.parseInt(cleaned[2] + cleaned[2], 16); + return { r, g, b }; + } + + if (cleaned.length === 6) { + const r = Number.parseInt(cleaned.substring(0, 2), 16); + const g = Number.parseInt(cleaned.substring(2, 4), 16); + const b = Number.parseInt(cleaned.substring(4, 6), 16); + return { r, g, b }; + } + + return null; +} + +/** + * Convert RGB to hex + */ +export function rgbToHex(r: number, g: number, b: number): string { + const toHex = (n: number) => { + const hex = Math.max(0, Math.min(255, Math.round(n))).toString(16); + return hex.length === 1 ? `0${hex}` : hex; + }; + return `#${toHex(r)}${toHex(g)}${toHex(b)}`; +} + +/** + * Convert RGB to HSL + */ +export function rgbToHsl(r: number, g: number, b: number): HSL { + const rNorm = r / 255; + const gNorm = g / 255; + const bNorm = b / 255; + + const max = Math.max(rNorm, gNorm, bNorm); + const min = Math.min(rNorm, gNorm, bNorm); + const delta = max - min; + + let h = 0; + let s = 0; + const l = (max + min) / 2; + + if (delta !== 0) { + s = l > 0.5 ? delta / (2 - max - min) : delta / (max + min); + + switch (max) { + case rNorm: + h = ((gNorm - bNorm) / delta + (gNorm < bNorm ? 6 : 0)) / 6; + break; + case gNorm: + h = ((bNorm - rNorm) / delta + 2) / 6; + break; + case bNorm: + h = ((rNorm - gNorm) / delta + 4) / 6; + break; + } + } + + return { + h: Math.round(h * 360), + s: Math.round(s * 100), + l: Math.round(l * 100), + }; +} + +/** + * Convert HSL to RGB + */ +export function hslToRgb(h: number, s: number, l: number): RGB { + const hNorm = h / 360; + const sNorm = s / 100; + const lNorm = l / 100; + + const hue2rgb = (p: number, q: number, t: number): number => { + let tNorm = t; + if (tNorm < 0) tNorm += 1; + if (tNorm > 1) tNorm -= 1; + if (tNorm < 1 / 6) return p + (q - p) * 6 * tNorm; + if (tNorm < 1 / 2) return q; + if (tNorm < 2 / 3) return p + (q - p) * (2 / 3 - tNorm) * 6; + return p; + }; + + if (sNorm === 0) { + const gray = Math.round(lNorm * 255); + return { r: gray, g: gray, b: gray }; + } + + const q = lNorm < 0.5 ? lNorm * (1 + sNorm) : lNorm + sNorm - lNorm * sNorm; + const p = 2 * lNorm - q; + + return { + r: Math.round(hue2rgb(p, q, hNorm + 1 / 3) * 255), + g: Math.round(hue2rgb(p, q, hNorm) * 255), + b: Math.round(hue2rgb(p, q, hNorm - 1 / 3) * 255), + }; +} + +/** + * Parse color string to ColorValue + */ +export function parseColor(color: string): ColorValue | null { + const trimmed = color.trim(); + + // Parse hex + if (trimmed.startsWith("#")) { + const rgb = hexToRgb(trimmed); + if (!rgb) return null; + const hsl = rgbToHsl(rgb.r, rgb.g, rgb.b); + return { + rgb: { ...rgb, a: 1 }, + hsl: { ...hsl, a: 1 }, + hex: trimmed, + }; + } + + // Parse rgb/rgba + const rgbMatch = trimmed.match( + /rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*([\d.]+)\s*)?\)/, + ); + if (rgbMatch) { + const r = Number.parseInt(rgbMatch[1], 10); + const g = Number.parseInt(rgbMatch[2], 10); + const b = Number.parseInt(rgbMatch[3], 10); + const a = rgbMatch[4] ? Number.parseFloat(rgbMatch[4]) : 1; + const hsl = rgbToHsl(r, g, b); + return { + rgb: { r, g, b, a }, + hsl: { ...hsl, a }, + hex: rgbToHex(r, g, b), + }; + } + + // Parse hsl/hsla + const hslMatch = trimmed.match( + /hsla?\(\s*([\d.]+)\s*,\s*([\d.]+)%\s*,\s*([\d.]+)%\s*(?:,\s*([\d.]+)\s*)?\)/, + ); + if (hslMatch) { + const h = Number.parseFloat(hslMatch[1]); + const s = Number.parseFloat(hslMatch[2]); + const l = Number.parseFloat(hslMatch[3]); + const a = hslMatch[4] ? Number.parseFloat(hslMatch[4]) : 1; + const rgb = hslToRgb(h, s, l); + return { + rgb: { ...rgb, a }, + hsl: { h, s, l, a }, + hex: rgbToHex(rgb.r, rgb.g, rgb.b), + }; + } + + return null; +} + +/** + * Format ColorValue to specified format string + */ +export function formatColor(color: ColorValue, format: ColorFormat): string { + switch (format) { + case "hex": + return color.hex; + case "rgb": + return `rgb(${color.rgb.r}, ${color.rgb.g}, ${color.rgb.b})`; + case "rgba": + return `rgba(${color.rgb.r}, ${color.rgb.g}, ${color.rgb.b}, ${color.rgb.a})`; + case "hsl": + return `hsl(${color.hsl.h}, ${color.hsl.s}%, ${color.hsl.l}%)`; + case "hsla": + return `hsla(${color.hsl.h}, ${color.hsl.s}%, ${color.hsl.l}%, ${color.hsl.a})`; + default: + return color.hex; + } +} + +/** + * Set alpha channel of a ColorValue + */ +export function setAlpha(color: ColorValue, alpha: number): ColorValue { + const clampedAlpha = Math.max(0, Math.min(1, alpha)); + return { + ...color, + rgb: { ...color.rgb, a: clampedAlpha }, + hsl: { ...color.hsl, a: clampedAlpha }, + }; +} + +/** + * Create ColorValue from HSL values + */ +export function createColorFromHsl( + h: number, + s: number, + l: number, + a = 1, +): ColorValue { + const rgb = hslToRgb(h, s, l); + return { + rgb: { ...rgb, a }, + hsl: { h, s, l, a }, + hex: rgbToHex(rgb.r, rgb.g, rgb.b), + }; +} + +/** + * Get default color value (white) + */ +export function getDefaultColor(): ColorValue { + return { + rgb: { r: 255, g: 255, b: 255, a: 1 }, + hsl: { h: 0, s: 0, l: 100, a: 1 }, + hex: "#ffffff", + }; +} diff --git a/src/components/colorpicker/ColorWheel.tsx b/src/components/colorpicker/ColorWheel.tsx new file mode 100644 index 0000000..7a02f0c --- /dev/null +++ b/src/components/colorpicker/ColorWheel.tsx @@ -0,0 +1,148 @@ +import { type JSX, createSignal, onMount, onCleanup } from "solid-js"; +import { clsx } from "clsx"; +import { twMerge } from "tailwind-merge"; +import { useColorPickerContext } from "./colorpickerContext"; +import { createColorFromHsl } from "./ColorUtils"; + +export interface ColorWheelProps { + class?: string; + className?: string; +} + +const ColorWheel = (props: ColorWheelProps): JSX.Element => { + const context = useColorPickerContext(); + const [isDragging, setIsDragging] = createSignal(false); + let containerRef: HTMLDivElement | undefined; + let cursorRef: HTMLDivElement | undefined; + + const hue = () => context.color().hsl.h; + const saturation = () => context.color().hsl.s; + const lightness = () => context.color().hsl.l; + + const updateColor = (x: number, y: number) => { + if (!containerRef || context.disabled()) return; + + const rect = containerRef.getBoundingClientRect(); + const centerX = rect.width / 2; + const centerY = rect.height / 2; + + // Calculate vector from center + const dx = x - rect.left - centerX; + const dy = y - rect.top - centerY; + + // Calculate angle (hue) + let angle = Math.atan2(dy, dx) * (180 / Math.PI) + 90; + if (angle < 0) angle += 360; + + // Calculate distance (saturation) + const distance = Math.sqrt(dx * dx + dy * dy); + const radius = rect.width / 2; + const s = Math.min(100, (distance / radius) * 100); + + const newColor = createColorFromHsl( + Math.round(angle), + Math.round(s), + lightness(), + context.color().hsl.a + ); + context.onChange(newColor); + }; + + const handleMouseDown = (e: MouseEvent) => { + if (context.disabled()) return; + setIsDragging(true); + updateColor(e.clientX, e.clientY); + e.preventDefault(); + }; + + const handleMouseMove = (e: MouseEvent) => { + if (isDragging()) { + updateColor(e.clientX, e.clientY); + e.preventDefault(); + } + }; + + const handleMouseUp = () => { + setIsDragging(false); + }; + + onMount(() => { + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("mouseup", handleMouseUp); + }); + + onCleanup(() => { + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + }); + + const classes = () => + twMerge( + "relative w-48 h-48 rounded-full cursor-crosshair select-none overflow-hidden mx-auto", + clsx({ + "opacity-50 cursor-not-allowed": context.disabled(), + }), + props.class, + props.className + ); + + const cursorPosition = () => { + const hRad = (hue() - 90) * (Math.PI / 180); + const sNorm = saturation() / 100; + + const x = 50 + 50 * sNorm * Math.cos(hRad); + const y = 50 + 50 * sNorm * Math.sin(hRad); + + return { x, y }; + }; + + const cursorClasses = () => + twMerge( + "absolute w-4 h-4 border-2 border-white rounded-full shadow-lg pointer-events-none transform -translate-x-1/2 -translate-y-1/2", + clsx({ + "ring-2 ring-primary": isDragging(), + }) + ); + + return ( +
+
+
+ ); +}; + +export default ColorWheel; diff --git a/src/components/colorpicker/HueSlider.tsx b/src/components/colorpicker/HueSlider.tsx new file mode 100644 index 0000000..ef12bf2 --- /dev/null +++ b/src/components/colorpicker/HueSlider.tsx @@ -0,0 +1,140 @@ +import { type JSX, createSignal, onMount, onCleanup } from "solid-js"; +import { clsx } from "clsx"; +import { twMerge } from "tailwind-merge"; +import { useColorPickerContext } from "./colorpickerContext"; +import { createColorFromHsl } from "./ColorUtils"; + +export interface HueSliderProps { + class?: string; + className?: string; +} + +const HueSlider = (props: HueSliderProps): JSX.Element => { + const context = useColorPickerContext(); + const [isDragging, setIsDragging] = createSignal(false); + let sliderRef: HTMLDivElement | undefined; + + const hue = () => context.color().hsl.h; + + const updateHue = (clientX: number) => { + if (!sliderRef || context.disabled()) return; + + const rect = sliderRef.getBoundingClientRect(); + const percentage = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)); + const newHue = Math.round(percentage * 360); + + const newColor = createColorFromHsl( + newHue, + context.color().hsl.s, + context.color().hsl.l, + context.color().hsl.a, + ); + context.onChange(newColor); + }; + + const handleMouseDown = (e: MouseEvent) => { + if (context.disabled()) return; + setIsDragging(true); + updateHue(e.clientX); + e.preventDefault(); + }; + + const handleMouseMove = (e: MouseEvent) => { + if (isDragging()) { + updateHue(e.clientX); + e.preventDefault(); + } + }; + + const handleMouseUp = () => { + setIsDragging(false); + }; + + const handleKeyDown = (e: KeyboardEvent) => { + if (context.disabled()) return; + + const step = e.shiftKey ? 10 : 1; + let newHue = hue(); + + switch (e.key) { + case "ArrowLeft": + case "ArrowDown": + newHue = (hue() - step + 360) % 360; + e.preventDefault(); + break; + case "ArrowRight": + case "ArrowUp": + newHue = (hue() + step) % 360; + e.preventDefault(); + break; + default: + return; + } + + const newColor = createColorFromHsl( + newHue, + context.color().hsl.s, + context.color().hsl.l, + context.color().hsl.a, + ); + context.onChange(newColor); + }; + + onMount(() => { + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("mouseup", handleMouseUp); + }); + + onCleanup(() => { + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + }); + + const classes = () => + twMerge( + "relative w-full h-6 rounded cursor-pointer select-none", + clsx({ + "opacity-50 cursor-not-allowed": context.disabled(), + }), + props.class, + props.className, + ); + + const thumbClasses = () => + twMerge( + "absolute top-1/2 w-4 h-4 border-2 border-white rounded-full shadow-lg transform -translate-x-1/2 -translate-y-1/2 pointer-events-none", + clsx({ + "ring-2 ring-primary": isDragging(), + }), + ); + + return ( +
+
+
+ ); +}; + +export default HueSlider; diff --git a/src/components/colorpicker/LightnessSlider.tsx b/src/components/colorpicker/LightnessSlider.tsx new file mode 100644 index 0000000..c5f4d42 --- /dev/null +++ b/src/components/colorpicker/LightnessSlider.tsx @@ -0,0 +1,145 @@ +import { type JSX, createSignal, onMount, onCleanup } from "solid-js"; +import { clsx } from "clsx"; +import { twMerge } from "tailwind-merge"; +import { useColorPickerContext } from "./colorpickerContext"; +import { createColorFromHsl } from "./ColorUtils"; + +export interface LightnessSliderProps { + class?: string; + className?: string; +} + +const LightnessSlider = (props: LightnessSliderProps): JSX.Element => { + const context = useColorPickerContext(); + const [isDragging, setIsDragging] = createSignal(false); + let sliderRef: HTMLDivElement | undefined; + + const lightness = () => context.color().hsl.l; + + const updateLightness = (clientX: number) => { + if (!sliderRef || context.disabled()) return; + + const rect = sliderRef.getBoundingClientRect(); + const percentage = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)); + const newLightness = Math.round(percentage * 100); + + const newColor = createColorFromHsl( + context.color().hsl.h, + context.color().hsl.s, + newLightness, + context.color().hsl.a, + ); + context.onChange(newColor); + }; + + const handleMouseDown = (e: MouseEvent) => { + if (context.disabled()) return; + setIsDragging(true); + updateLightness(e.clientX); + e.preventDefault(); + }; + + const handleMouseMove = (e: MouseEvent) => { + if (isDragging()) { + updateLightness(e.clientX); + e.preventDefault(); + } + }; + + const handleMouseUp = () => { + setIsDragging(false); + }; + + const handleKeyDown = (e: KeyboardEvent) => { + if (context.disabled()) return; + + const step = e.shiftKey ? 10 : 1; + let newL = lightness(); + + switch (e.key) { + case "ArrowLeft": + case "ArrowDown": + newL = Math.max(0, newL - step); + e.preventDefault(); + break; + case "ArrowRight": + case "ArrowUp": + newL = Math.min(100, newL + step); + e.preventDefault(); + break; + default: + return; + } + + const newColor = createColorFromHsl( + context.color().hsl.h, + context.color().hsl.s, + newL, + context.color().hsl.a, + ); + context.onChange(newColor); + }; + + onMount(() => { + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("mouseup", handleMouseUp); + }); + + onCleanup(() => { + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + }); + + const classes = () => + twMerge( + "relative w-full h-6 rounded cursor-pointer select-none", + clsx({ + "opacity-50 cursor-not-allowed": context.disabled(), + }), + props.class, + props.className, + ); + + const thumbClasses = () => + twMerge( + "absolute top-1/2 w-4 h-4 border-2 border-white rounded-full shadow-lg transform -translate-x-1/2 -translate-y-1/2 pointer-events-none", + clsx({ + "ring-2 ring-primary": isDragging(), + }), + ); + + const gradientStyle = () => { + const h = context.color().hsl.h; + const s = context.color().hsl.s; + return `linear-gradient(to right, hsl(${h}, ${s}%, 0%), hsl(${h}, ${s}%, 50%), hsl(${h}, ${s}%, 100%))`; + }; + + return ( +
+
+
+ ); +}; + +export default LightnessSlider; diff --git a/src/components/colorpicker/SaturationBrightness.tsx b/src/components/colorpicker/SaturationBrightness.tsx new file mode 100644 index 0000000..08bd76d --- /dev/null +++ b/src/components/colorpicker/SaturationBrightness.tsx @@ -0,0 +1,157 @@ +import { type JSX, Show, createSignal, onMount, onCleanup } from "solid-js"; +import { clsx } from "clsx"; +import { twMerge } from "tailwind-merge"; +import { useColorPickerContext } from "./colorpickerContext"; +import { createColorFromHsl } from "./ColorUtils"; + +export interface SaturationBrightnessProps { + class?: string; + className?: string; +} + +const SaturationBrightness = ( + props: SaturationBrightnessProps, +): JSX.Element => { + const context = useColorPickerContext(); + const [isDragging, setIsDragging] = createSignal(false); + let containerRef: HTMLDivElement | undefined; + let cursorRef: HTMLDivElement | undefined; + + const saturation = () => context.color().hsl.s; + const brightness = () => 100 - context.color().hsl.l; + + const updateColor = (x: number, y: number) => { + if (!containerRef || context.disabled()) return; + + const rect = containerRef.getBoundingClientRect(); + const s = Math.max(0, Math.min(100, ((x - rect.left) / rect.width) * 100)); + const l = Math.max( + 0, + Math.min(100, 100 - ((y - rect.top) / rect.height) * 100), + ); + + const newColor = createColorFromHsl( + context.color().hsl.h, + s, + l, + context.color().hsl.a, + ); + context.onChange(newColor); + }; + + const handleMouseDown = (e: MouseEvent) => { + if (context.disabled()) return; + setIsDragging(true); + updateColor(e.clientX, e.clientY); + e.preventDefault(); + }; + + const handleMouseMove = (e: MouseEvent) => { + if (isDragging()) { + updateColor(e.clientX, e.clientY); + e.preventDefault(); + } + }; + + const handleMouseUp = () => { + setIsDragging(false); + }; + + const handleKeyDown = (e: KeyboardEvent) => { + if (context.disabled()) return; + + const step = e.shiftKey ? 10 : 1; + let s = saturation(); + let l = 100 - brightness(); + + switch (e.key) { + case "ArrowLeft": + s = Math.max(0, s - step); + e.preventDefault(); + break; + case "ArrowRight": + s = Math.min(100, s + step); + e.preventDefault(); + break; + case "ArrowUp": + l = Math.min(100, l + step); + e.preventDefault(); + break; + case "ArrowDown": + l = Math.max(0, l - step); + e.preventDefault(); + break; + default: + return; + } + + const newColor = createColorFromHsl( + context.color().hsl.h, + s, + l, + context.color().hsl.a, + ); + context.onChange(newColor); + }; + + onMount(() => { + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("mouseup", handleMouseUp); + }); + + onCleanup(() => { + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + }); + + const classes = () => + twMerge( + "relative w-full h-48 rounded cursor-crosshair select-none", + clsx({ + "opacity-50 cursor-not-allowed": context.disabled(), + }), + props.class, + props.className, + ); + + const cursorClasses = () => + twMerge( + "absolute w-4 h-4 border-2 border-white rounded-full shadow-lg pointer-events-none transform -translate-x-1/2 -translate-y-1/2", + clsx({ + "ring-2 ring-primary": isDragging(), + }), + ); + + return ( +
+
+
+ ); +}; + +export default SaturationBrightness; diff --git a/src/components/colorpicker/colorpickerContext.ts b/src/components/colorpicker/colorpickerContext.ts new file mode 100644 index 0000000..61efdde --- /dev/null +++ b/src/components/colorpicker/colorpickerContext.ts @@ -0,0 +1,24 @@ +import { type Accessor, createContext, useContext } from "solid-js"; +import type { ColorValue, ColorFormat } from "./ColorUtils"; + +export interface ColorPickerContextType { + color: Accessor; + format: Accessor; + disabled: Accessor; + onChange: (color: ColorValue) => void; + onFormatChange: (format: ColorFormat) => void; +} + +export const ColorPickerContext = createContext< + ColorPickerContextType | undefined +>(undefined); + +export function useColorPickerContext(): ColorPickerContextType { + const context = useContext(ColorPickerContext); + if (!context) { + throw new Error( + "useColorPickerContext must be used within a ColorPickerContext.Provider", + ); + } + return context; +} diff --git a/src/components/colorpicker/index.ts b/src/components/colorpicker/index.ts new file mode 100644 index 0000000..42e0bd0 --- /dev/null +++ b/src/components/colorpicker/index.ts @@ -0,0 +1,5 @@ +export { default as ColorPicker, default } from "./ColorPicker"; +export type { ColorPickerProps, ColorFormat, ColorPickerMode } from "./ColorPicker"; + +export type { ColorValue, RGB, RGBA, HSL, HSLA } from "./ColorUtils"; + diff --git a/src/index.ts b/src/index.ts index daae479..fbd73af 100644 --- a/src/index.ts +++ b/src/index.ts @@ -18,6 +18,8 @@ export { default as Carousel } from "./components/carousel"; export type { CarouselItemProps, CarouselProps } from "./components/carousel"; export { default as ChatBubble } from "./components/chatbubble"; export { default as Checkbox } from "./components/checkbox"; +export { default as ColorPicker } from "./components/colorpicker"; +export type { ColorPickerProps, ColorFormat, ColorValue } from "./components/colorpicker"; export { CodeMockup, CodeMockupLine } from "./components/codemockup"; export { Collapse,