From c0f0841463b242f847bd001ce8bbe9bfcc257a5d Mon Sep 17 00:00:00 2001 From: Ariq Pradipa Santoso Date: Tue, 5 Aug 2025 07:12:49 +0700 Subject: [PATCH 1/7] feat: enhance ColorPicker with validation and editing features --- .../settings/sections/spaces/color-picker.tsx | 365 ++++++++++++++---- 1 file changed, 290 insertions(+), 75 deletions(-) diff --git a/src/renderer/src/components/settings/sections/spaces/color-picker.tsx b/src/renderer/src/components/settings/sections/spaces/color-picker.tsx index ce0f122b..071bdf6f 100644 --- a/src/renderer/src/components/settings/sections/spaces/color-picker.tsx +++ b/src/renderer/src/components/settings/sections/spaces/color-picker.tsx @@ -1,7 +1,10 @@ -import { useRef, useState } from "react"; +import { useRef, useState, useEffect } from "react"; import { Label } from "@/components/ui/label"; -import { motion } from "motion/react"; -import { Palette } from "lucide-react"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { motion, AnimatePresence } from "motion/react"; +import { Palette, Edit3, Check, X, Pipette } from "lucide-react"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; // ============================== // ColorPicker Component @@ -13,59 +16,157 @@ interface ColorPickerProps { } export function ColorPicker({ defaultColor, label, onChange }: ColorPickerProps) { - const inputRef = useRef(null); + const colorInputRef = useRef(null); + const textInputRef = useRef(null); const [previewColor, setPreviewColor] = useState(defaultColor || "#ffffff"); + const [textValue, setTextValue] = useState(defaultColor || "#ffffff"); const [isFocused, setIsFocused] = useState(false); + const [isEditingText, setIsEditingText] = useState(false); + const [validationError, setValidationError] = useState(""); const colorChangeTimeoutRef = useRef(null); - // Handle color change without re-rendering on every input - const handleColorChange = () => { + // Sync text value when defaultColor changes (for randomize functionality) + useEffect(() => { + setTextValue(defaultColor || "#ffffff"); + setPreviewColor(defaultColor || "#ffffff"); + }, [defaultColor]); + + // Validate hex color format + const isValidHexColor = (hex: string): boolean => { + const hexRegex = /^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/; + return hexRegex.test(hex); + }; + + // Normalize hex color (convert 3-digit to 6-digit) + const normalizeHexColor = (hex: string): string => { + if (hex.length === 4) { + // Convert #rgb to #rrggbb + return "#" + hex[1] + hex[1] + hex[2] + hex[2] + hex[3] + hex[3]; + } + return hex; + }; + + // Handle color picker change + const handleColorPickerChange = () => { if (colorChangeTimeoutRef.current) { clearTimeout(colorChangeTimeoutRef.current); } - if (inputRef.current) { - const newColor = inputRef.current.value; + if (colorInputRef.current) { + const newColor = colorInputRef.current.value; + setPreviewColor(newColor); + setTextValue(newColor); + setValidationError(""); colorChangeTimeoutRef.current = setTimeout(() => { - setPreviewColor(newColor); onChange(newColor); }, 100); } }; - // Update preview color without triggering re-renders in parent - const handlePreviewUpdate = (e: React.ChangeEvent) => { + // Handle color picker preview update + const handleColorPickerPreview = (e: React.ChangeEvent) => { setPreviewColor(e.target.value); + setTextValue(e.target.value); }; - // Only call onChange when focus is lost (user is done selecting) - const handleBlur = () => { - setIsFocused(false); - if (inputRef.current) { - onChange(inputRef.current.value); + // Handle text input change + const handleTextChange = (e: React.ChangeEvent) => { + const value = e.target.value; + setTextValue(value); + + // Clear previous validation error + setValidationError(""); + + // Add # if not present and value is not empty + let normalizedValue = value; + if (value && !value.startsWith("#")) { + normalizedValue = "#" + value; + setTextValue(normalizedValue); + } + + // Validate and update if valid + if (normalizedValue && isValidHexColor(normalizedValue)) { + const finalColor = normalizeHexColor(normalizedValue); + setPreviewColor(finalColor); + setTextValue(finalColor); + + // Update color picker input + if (colorInputRef.current) { + colorInputRef.current.value = finalColor; + } + + onChange(finalColor); + } else if (normalizedValue && normalizedValue !== "#") { + setValidationError("Invalid hex color format"); } }; - const handleFocus = () => { - setIsFocused(true); + // Handle text input blur + const handleTextBlur = () => { + setIsEditingText(false); + + if (!textValue || textValue === "#") { + setTextValue(previewColor); + setValidationError(""); + return; + } + + if (!isValidHexColor(textValue)) { + setValidationError("Invalid hex color format"); + setTextValue(previewColor); // Reset to last valid color + } else { + const finalColor = normalizeHexColor(textValue); + setPreviewColor(finalColor); + setTextValue(finalColor); + + if (colorInputRef.current) { + colorInputRef.current.value = finalColor; + } + + onChange(finalColor); + setValidationError(""); + } }; - // Get contrasting text color for the preview - const getContrastingTextColor = (hexColor: string) => { - // Convert hex to RGB - const r = parseInt(hexColor.slice(1, 3), 16); - const g = parseInt(hexColor.slice(3, 5), 16); - const b = parseInt(hexColor.slice(5, 7), 16); + // Handle text input key press + const handleTextKeyPress = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + handleTextBlur(); + textInputRef.current?.blur(); + } else if (e.key === "Escape") { + setTextValue(previewColor); + setValidationError(""); + setIsEditingText(false); + textInputRef.current?.blur(); + } + }; - // Calculate luminance - const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255; + // Handle color picker focus events + const handleColorPickerBlur = () => { + setIsFocused(false); + if (colorInputRef.current) { + onChange(colorInputRef.current.value); + } + }; - return luminance > 0.5 ? "#000000" : "#ffffff"; + const handleColorPickerFocus = () => { + setIsFocused(true); }; - const textColor = getContrastingTextColor(previewColor); + // Toggle edit mode + const handleToggleEdit = () => { + if (isEditingText) { + handleTextBlur(); + } else { + setIsEditingText(true); + setTimeout(() => { + textInputRef.current?.focus(); + textInputRef.current?.select(); + }, 50); + } + }; return (
@@ -76,55 +177,169 @@ export function ColorPicker({ defaultColor, label, onChange }: ColorPickerProps) {label} -
- -
- -
- - -
- {previewColor.toUpperCase()} - + {/* Color Preview Circle */} + + + colorInputRef.current?.click()} + > +
+
+ +
+ + + +

Click to open color picker

+
+ + + {/* Hidden Color Input */} + + + {/* Text Input Area */} +
+
+
+ + {isEditingText ? ( + + + + ) : ( + +
+ {previewColor.toUpperCase()} + + + Edit + +
+
+ )} +
+
+ + {/* Action Buttons */} + + {isEditingText && ( + + + + + + +

Apply color

+
+
+ + + + + + +

Cancel

+
+
+
+ )} +
+
+ + {/* Validation Error */} + + {validationError && ( + - {isFocused ? "Selecting" : "Change"} - -
- + + {validationError} + + )} +
From 3876af36d0f13e2433fefe453123061df4f2910a Mon Sep 17 00:00:00 2001 From: Ariq Pradipa Santoso Date: Tue, 5 Aug 2025 07:27:30 +0700 Subject: [PATCH 2/7] feat: enhance SpaceEditor to track unsaved changes and update state on save --- .../settings/sections/spaces/space-editor.tsx | 57 ++++++++++++------- 1 file changed, 35 insertions(+), 22 deletions(-) diff --git a/src/renderer/src/components/settings/sections/spaces/space-editor.tsx b/src/renderer/src/components/settings/sections/spaces/space-editor.tsx index 18c6439d..a93e5520 100644 --- a/src/renderer/src/components/settings/sections/spaces/space-editor.tsx +++ b/src/renderer/src/components/settings/sections/spaces/space-editor.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useState, useEffect } from "react"; import { Button } from "@/components/ui/button"; import { ArrowLeft, Loader2, Save, Settings, Trash2, PaintBucket, Check } from "lucide-react"; import type { Space } from "~/flow/interfaces/sessions/spaces"; @@ -18,6 +18,7 @@ interface SpaceEditorProps { export function SpaceEditor({ space, onClose, onDelete, onSpacesUpdate }: SpaceEditorProps) { // State management const [editedSpace, setEditedSpace] = useState({ ...space }); + const [currentSpace, setCurrentSpace] = useState({ ...space }); // Track the current saved state const [activeTab, setActiveTab] = useState("basic"); const [isSaving, setIsSaving] = useState(false); const [saveSuccess, setSaveSuccess] = useState(false); @@ -29,6 +30,32 @@ export function SpaceEditor({ space, onClose, onDelete, onSpacesUpdate }: SpaceE setEditedSpace((prev) => ({ ...prev, ...updates })); }; + // Detect if there are unsaved changes + const hasChanges = () => { + return ( + editedSpace.name !== currentSpace.name || + editedSpace.bgStartColor !== currentSpace.bgStartColor || + editedSpace.bgEndColor !== currentSpace.bgEndColor || + editedSpace.icon !== currentSpace.icon + ); + }; + + // Reset save success state when changes are detected + useEffect(() => { + if (saveSuccess) { + const currentHasChanges = ( + editedSpace.name !== currentSpace.name || + editedSpace.bgStartColor !== currentSpace.bgStartColor || + editedSpace.bgEndColor !== currentSpace.bgEndColor || + editedSpace.icon !== currentSpace.icon + ); + + if (currentHasChanges) { + setSaveSuccess(false); + } + } + }, [editedSpace.name, editedSpace.bgStartColor, editedSpace.bgEndColor, editedSpace.icon, saveSuccess, currentSpace.name, currentSpace.bgStartColor, currentSpace.bgEndColor, currentSpace.icon]); + // Handle space update const handleSave = async () => { setIsSaving(true); @@ -37,19 +64,19 @@ export function SpaceEditor({ space, onClose, onDelete, onSpacesUpdate }: SpaceE // Only send the fields that have changed const updatedFields: Partial = {}; - if (editedSpace.name !== space.name) { + if (editedSpace.name !== currentSpace.name) { updatedFields.name = editedSpace.name; } - if (editedSpace.bgStartColor !== space.bgStartColor) { + if (editedSpace.bgStartColor !== currentSpace.bgStartColor) { updatedFields.bgStartColor = editedSpace.bgStartColor; } - if (editedSpace.bgEndColor !== space.bgEndColor) { + if (editedSpace.bgEndColor !== currentSpace.bgEndColor) { updatedFields.bgEndColor = editedSpace.bgEndColor; } - if (editedSpace.icon !== space.icon) { + if (editedSpace.icon !== currentSpace.icon) { updatedFields.icon = editedSpace.icon; } @@ -58,14 +85,10 @@ export function SpaceEditor({ space, onClose, onDelete, onSpacesUpdate }: SpaceE await flow.spaces.updateSpace(space.profileId, space.id, updatedFields); onSpacesUpdate(); // Refetch spaces after successful update - setSaveSuccess(true); - // Auto-close after short delay - setTimeout(() => { - if (saveSuccess) { - onClose(); - } - }, 1500); + // Update current space state with the saved values + setCurrentSpace({ ...editedSpace }); + setSaveSuccess(true); } else { // No changes to save onClose(); @@ -99,16 +122,6 @@ export function SpaceEditor({ space, onClose, onDelete, onSpacesUpdate }: SpaceE }); }; - // Detect if there are unsaved changes - const hasChanges = () => { - return ( - editedSpace.name !== space.name || - editedSpace.bgStartColor !== space.bgStartColor || - editedSpace.bgEndColor !== space.bgEndColor || - editedSpace.icon !== space.icon - ); - }; - return ( Date: Tue, 5 Aug 2025 07:57:36 +0700 Subject: [PATCH 3/7] fix: ColorPicker validation and handling of text input changes --- .../settings/sections/spaces/color-picker.tsx | 41 +++++++++++-------- 1 file changed, 23 insertions(+), 18 deletions(-) diff --git a/src/renderer/src/components/settings/sections/spaces/color-picker.tsx b/src/renderer/src/components/settings/sections/spaces/color-picker.tsx index 071bdf6f..af3bf3c8 100644 --- a/src/renderer/src/components/settings/sections/spaces/color-picker.tsx +++ b/src/renderer/src/components/settings/sections/spaces/color-picker.tsx @@ -79,45 +79,49 @@ export function ColorPicker({ defaultColor, label, onChange }: ColorPickerProps) // Clear previous validation error setValidationError(""); - // Add # if not present and value is not empty + // Only validate, don't auto-format while typing let normalizedValue = value; if (value && !value.startsWith("#")) { normalizedValue = "#" + value; - setTextValue(normalizedValue); } - // Validate and update if valid + // Just validate - don't update the text field or preview while typing if (normalizedValue && isValidHexColor(normalizedValue)) { - const finalColor = normalizeHexColor(normalizedValue); - setPreviewColor(finalColor); - setTextValue(finalColor); - - // Update color picker input - if (colorInputRef.current) { - colorInputRef.current.value = finalColor; - } - - onChange(finalColor); + // Valid color - clear any error but don't update preview until blur/enter + setValidationError(""); } else if (normalizedValue && normalizedValue !== "#") { setValidationError("Invalid hex color format"); } }; - // Handle text input blur + // Handle text input blur (only exit edit mode, don't auto-save) const handleTextBlur = () => { setIsEditingText(false); + // Reset to previous valid color when just clicking outside + setTextValue(previewColor); + setValidationError(""); + }; + // Apply changes (used by Enter key and Apply button) + const applyChanges = () => { if (!textValue || textValue === "#") { setTextValue(previewColor); setValidationError(""); + setIsEditingText(false); return; } - if (!isValidHexColor(textValue)) { + // Add # if not present + let normalizedValue = textValue; + if (textValue && !textValue.startsWith("#")) { + normalizedValue = "#" + textValue; + } + + if (!isValidHexColor(normalizedValue)) { setValidationError("Invalid hex color format"); setTextValue(previewColor); // Reset to last valid color } else { - const finalColor = normalizeHexColor(textValue); + const finalColor = normalizeHexColor(normalizedValue); setPreviewColor(finalColor); setTextValue(finalColor); @@ -128,12 +132,13 @@ export function ColorPicker({ defaultColor, label, onChange }: ColorPickerProps) onChange(finalColor); setValidationError(""); } + setIsEditingText(false); }; // Handle text input key press const handleTextKeyPress = (e: React.KeyboardEvent) => { if (e.key === "Enter") { - handleTextBlur(); + applyChanges(); textInputRef.current?.blur(); } else if (e.key === "Escape") { setTextValue(previewColor); @@ -158,7 +163,7 @@ export function ColorPicker({ defaultColor, label, onChange }: ColorPickerProps) // Toggle edit mode const handleToggleEdit = () => { if (isEditingText) { - handleTextBlur(); + applyChanges(); } else { setIsEditingText(true); setTimeout(() => { From 7b05e6eb8008101bdfc3f0469a14c726f8b1a32f Mon Sep 17 00:00:00 2001 From: Ariq Pradipa Santoso Date: Tue, 5 Aug 2025 07:59:11 +0700 Subject: [PATCH 4/7] refactor: optimize unsaved changes detection with useCallback --- .../settings/sections/spaces/space-editor.tsx | 21 ++++++------------- 1 file changed, 6 insertions(+), 15 deletions(-) diff --git a/src/renderer/src/components/settings/sections/spaces/space-editor.tsx b/src/renderer/src/components/settings/sections/spaces/space-editor.tsx index a93e5520..c8404bdb 100644 --- a/src/renderer/src/components/settings/sections/spaces/space-editor.tsx +++ b/src/renderer/src/components/settings/sections/spaces/space-editor.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useCallback } from "react"; import { Button } from "@/components/ui/button"; import { ArrowLeft, Loader2, Save, Settings, Trash2, PaintBucket, Check } from "lucide-react"; import type { Space } from "~/flow/interfaces/sessions/spaces"; @@ -31,30 +31,21 @@ export function SpaceEditor({ space, onClose, onDelete, onSpacesUpdate }: SpaceE }; // Detect if there are unsaved changes - const hasChanges = () => { + const hasChanges = useCallback(() => { return ( editedSpace.name !== currentSpace.name || editedSpace.bgStartColor !== currentSpace.bgStartColor || editedSpace.bgEndColor !== currentSpace.bgEndColor || editedSpace.icon !== currentSpace.icon ); - }; + }, [editedSpace.name, editedSpace.bgStartColor, editedSpace.bgEndColor, editedSpace.icon, currentSpace.name, currentSpace.bgStartColor, currentSpace.bgEndColor, currentSpace.icon]); // Reset save success state when changes are detected useEffect(() => { - if (saveSuccess) { - const currentHasChanges = ( - editedSpace.name !== currentSpace.name || - editedSpace.bgStartColor !== currentSpace.bgStartColor || - editedSpace.bgEndColor !== currentSpace.bgEndColor || - editedSpace.icon !== currentSpace.icon - ); - - if (currentHasChanges) { - setSaveSuccess(false); - } + if (saveSuccess && hasChanges()) { + setSaveSuccess(false); } - }, [editedSpace.name, editedSpace.bgStartColor, editedSpace.bgEndColor, editedSpace.icon, saveSuccess, currentSpace.name, currentSpace.bgStartColor, currentSpace.bgEndColor, currentSpace.icon]); + }, [hasChanges, saveSuccess]); // Handle space update const handleSave = async () => { From 868705b9edf4fdd4bd82de314c3df869137a4d4a Mon Sep 17 00:00:00 2001 From: Ariq Pradipa Santoso Date: Tue, 5 Aug 2025 08:07:47 +0700 Subject: [PATCH 5/7] fix: ColorPicker state management and validation --- .../settings/sections/spaces/color-picker.tsx | 355 ++++++++++-------- 1 file changed, 196 insertions(+), 159 deletions(-) diff --git a/src/renderer/src/components/settings/sections/spaces/color-picker.tsx b/src/renderer/src/components/settings/sections/spaces/color-picker.tsx index af3bf3c8..618f983a 100644 --- a/src/renderer/src/components/settings/sections/spaces/color-picker.tsx +++ b/src/renderer/src/components/settings/sections/spaces/color-picker.tsx @@ -1,4 +1,4 @@ -import { useRef, useState, useEffect } from "react"; +import { useRef, useState, useEffect, useCallback } from "react"; import { Label } from "@/components/ui/label"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; @@ -13,172 +13,200 @@ interface ColorPickerProps { defaultColor: string; label: string; onChange: (color: string) => void; + disabled?: boolean; + className?: string; } -export function ColorPicker({ defaultColor, label, onChange }: ColorPickerProps) { +export function ColorPicker({ + defaultColor, + label, + onChange, + disabled = false, + className = "" +}: ColorPickerProps) { const colorInputRef = useRef(null); const textInputRef = useRef(null); - const [previewColor, setPreviewColor] = useState(defaultColor || "#ffffff"); - const [textValue, setTextValue] = useState(defaultColor || "#ffffff"); - const [isFocused, setIsFocused] = useState(false); - const [isEditingText, setIsEditingText] = useState(false); - const [validationError, setValidationError] = useState(""); + const timeoutRef = useRef(null); - const colorChangeTimeoutRef = useRef(null); + // Consolidated state + const [state, setState] = useState({ + currentColor: defaultColor || "#ffffff", + textValue: defaultColor || "#ffffff", + isFocused: false, + isEditingText: false, + validationError: "" + }); - // Sync text value when defaultColor changes (for randomize functionality) + // Sync with defaultColor changes useEffect(() => { - setTextValue(defaultColor || "#ffffff"); - setPreviewColor(defaultColor || "#ffffff"); + const newColor = defaultColor || "#ffffff"; + setState(prev => ({ + ...prev, + currentColor: newColor, + textValue: newColor + })); }, [defaultColor]); - // Validate hex color format - const isValidHexColor = (hex: string): boolean => { - const hexRegex = /^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/; - return hexRegex.test(hex); - }; + // Cleanup timeout on unmount + useEffect(() => { + return () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + }; + }, []); - // Normalize hex color (convert 3-digit to 6-digit) - const normalizeHexColor = (hex: string): string => { + // Utility functions + const isValidHexColor = useCallback((hex: string): boolean => { + // More strict hex validation - exactly 3 or 6 characters after # + return /^#([A-Fa-f0-9]{3}|[A-Fa-f0-9]{6})$/.test(hex); + }, []); + + const normalizeHexColor = useCallback((hex: string): string => { if (hex.length === 4) { - // Convert #rgb to #rrggbb - return "#" + hex[1] + hex[1] + hex[2] + hex[2] + hex[3] + hex[3]; + return "#" + hex[1].repeat(2) + hex[2].repeat(2) + hex[3].repeat(2); } return hex; - }; + }, []); + + const updateState = useCallback((updates: Partial) => { + setState(prev => ({ ...prev, ...updates })); + }, []); - // Handle color picker change - const handleColorPickerChange = () => { - if (colorChangeTimeoutRef.current) { - clearTimeout(colorChangeTimeoutRef.current); + // Debounced onChange call with cleanup + const debouncedOnChange = useCallback((color: string) => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); } + timeoutRef.current = setTimeout(() => { + onChange(color); + timeoutRef.current = null; + }, 100); + }, [onChange]); + // Color picker handlers + const handleColorPickerChange = useCallback((e: React.ChangeEvent) => { + if (disabled) return; + + const newColor = e.target.value; + updateState({ + currentColor: newColor, + textValue: newColor, + validationError: "" + }); + debouncedOnChange(newColor); + }, [updateState, debouncedOnChange, disabled]); + + const handleColorPickerFocus = useCallback(() => { + if (!disabled) { + updateState({ isFocused: true }); + } + }, [updateState, disabled]); + + const handleColorPickerBlur = useCallback(() => { + updateState({ isFocused: false }); if (colorInputRef.current) { - const newColor = colorInputRef.current.value; - setPreviewColor(newColor); - setTextValue(newColor); - setValidationError(""); - - colorChangeTimeoutRef.current = setTimeout(() => { - onChange(newColor); - }, 100); + onChange(colorInputRef.current.value); } - }; + }, [updateState, onChange]); - // Handle color picker preview update - const handleColorPickerPreview = (e: React.ChangeEvent) => { - setPreviewColor(e.target.value); - setTextValue(e.target.value); - }; + // Text input handlers + const handleTextChange = useCallback((e: React.ChangeEvent) => { + if (disabled) return; - // Handle text input change - const handleTextChange = (e: React.ChangeEvent) => { const value = e.target.value; - setTextValue(value); + const normalizedValue = value.startsWith("#") ? value : "#" + value; - // Clear previous validation error - setValidationError(""); + const error = normalizedValue !== "#" && !isValidHexColor(normalizedValue) + ? "Invalid hex color format (use #RGB or #RRGGBB)" + : ""; - // Only validate, don't auto-format while typing - let normalizedValue = value; - if (value && !value.startsWith("#")) { - normalizedValue = "#" + value; - } + updateState({ + textValue: value, + validationError: error + }); + }, [updateState, isValidHexColor, disabled]); + + // Text editing actions + const cancelTextEdit = useCallback(() => { + updateState({ + textValue: state.currentColor, + isEditingText: false, + validationError: "" + }); + }, [state.currentColor, updateState]); + + const applyTextChanges = useCallback(() => { + const { textValue } = state; - // Just validate - don't update the text field or preview while typing - if (normalizedValue && isValidHexColor(normalizedValue)) { - // Valid color - clear any error but don't update preview until blur/enter - setValidationError(""); - } else if (normalizedValue && normalizedValue !== "#") { - setValidationError("Invalid hex color format"); - } - }; - - // Handle text input blur (only exit edit mode, don't auto-save) - const handleTextBlur = () => { - setIsEditingText(false); - // Reset to previous valid color when just clicking outside - setTextValue(previewColor); - setValidationError(""); - }; - - // Apply changes (used by Enter key and Apply button) - const applyChanges = () => { if (!textValue || textValue === "#") { - setTextValue(previewColor); - setValidationError(""); - setIsEditingText(false); + cancelTextEdit(); return; } - // Add # if not present - let normalizedValue = textValue; - if (textValue && !textValue.startsWith("#")) { - normalizedValue = "#" + textValue; - } + const normalizedValue = textValue.startsWith("#") ? textValue : "#" + textValue; if (!isValidHexColor(normalizedValue)) { - setValidationError("Invalid hex color format"); - setTextValue(previewColor); // Reset to last valid color - } else { - const finalColor = normalizeHexColor(normalizedValue); - setPreviewColor(finalColor); - setTextValue(finalColor); + updateState({ + validationError: "Invalid hex color format (use #RGB or #RRGGBB)", + textValue: state.currentColor + }); + return; + } - if (colorInputRef.current) { - colorInputRef.current.value = finalColor; - } + const finalColor = normalizeHexColor(normalizedValue); + + updateState({ + currentColor: finalColor, + textValue: finalColor, + isEditingText: false, + validationError: "" + }); - onChange(finalColor); - setValidationError(""); + if (colorInputRef.current) { + colorInputRef.current.value = finalColor; } - setIsEditingText(false); - }; - // Handle text input key press - const handleTextKeyPress = (e: React.KeyboardEvent) => { + onChange(finalColor); + }, [state, updateState, isValidHexColor, normalizeHexColor, onChange, cancelTextEdit]); + + const handleTextKeyPress = useCallback((e: React.KeyboardEvent) => { if (e.key === "Enter") { - applyChanges(); - textInputRef.current?.blur(); + e.preventDefault(); + applyTextChanges(); } else if (e.key === "Escape") { - setTextValue(previewColor); - setValidationError(""); - setIsEditingText(false); - textInputRef.current?.blur(); + e.preventDefault(); + cancelTextEdit(); } - }; + }, [applyTextChanges, cancelTextEdit]); - // Handle color picker focus events - const handleColorPickerBlur = () => { - setIsFocused(false); - if (colorInputRef.current) { - onChange(colorInputRef.current.value); + const handleTextBlur = useCallback(() => { + if (state.isEditingText) { + cancelTextEdit(); } - }; + }, [state.isEditingText, cancelTextEdit]); - const handleColorPickerFocus = () => { - setIsFocused(true); - }; + const toggleEditMode = useCallback(() => { + if (disabled) return; - // Toggle edit mode - const handleToggleEdit = () => { - if (isEditingText) { - applyChanges(); + if (state.isEditingText) { + applyTextChanges(); } else { - setIsEditingText(true); - setTimeout(() => { + updateState({ isEditingText: true }); + // Focus and select text after state update + requestAnimationFrame(() => { textInputRef.current?.focus(); textInputRef.current?.select(); - }, 50); + }); } - }; + }, [state.isEditingText, updateState, applyTextChanges, disabled]); + + // Generate component ID + const componentId = `color-picker-${label.toLowerCase().replace(/\s+/g, "-")}`; return ( -
-