11'use client'
22
3+ import { useCallback , useEffect , useMemo , useState } from 'react'
4+ import { Check } from 'lucide-react'
35import {
46 Popover ,
57 PopoverAnchor ,
@@ -12,6 +14,29 @@ import {
1214import { cn } from '@/lib/core/utils/cn'
1315import { WORKFLOW_COLORS } from '@/lib/workflows/colors'
1416
17+ /**
18+ * Validates a hex color string.
19+ * Accepts 3 or 6 character hex codes with or without #.
20+ */
21+ function isValidHex ( hex : string ) : boolean {
22+ const cleaned = hex . replace ( '#' , '' )
23+ return / ^ [ 0 - 9 A - F a - f ] { 3 } $ | ^ [ 0 - 9 A - F a - f ] { 6 } $ / . test ( cleaned )
24+ }
25+
26+ /**
27+ * Normalizes a hex color to lowercase 6-character format with #.
28+ */
29+ function normalizeHex ( hex : string ) : string {
30+ let cleaned = hex . replace ( '#' , '' ) . toLowerCase ( )
31+ if ( cleaned . length === 3 ) {
32+ cleaned = cleaned
33+ . split ( '' )
34+ . map ( ( c ) => c + c )
35+ . join ( '' )
36+ }
37+ return `#${ cleaned } `
38+ }
39+
1540interface ContextMenuProps {
1641 /**
1742 * Whether the context menu is open
@@ -173,7 +198,51 @@ export function ContextMenu({
173198 disableCreate = false ,
174199 disableCreateFolder = false ,
175200} : ContextMenuProps ) {
176- // Section visibility for divider logic
201+ const [ hexInput , setHexInput ] = useState ( currentColor || '#ffffff' )
202+
203+ // Sync hexInput when currentColor changes (e.g., opening menu on different workflow)
204+ useEffect ( ( ) => {
205+ setHexInput ( currentColor || '#ffffff' )
206+ } , [ currentColor ] )
207+
208+ const canSubmitHex = useMemo ( ( ) => {
209+ if ( ! isValidHex ( hexInput ) ) return false
210+ const normalized = normalizeHex ( hexInput )
211+ if ( currentColor && normalized . toLowerCase ( ) === currentColor . toLowerCase ( ) ) return false
212+ return true
213+ } , [ hexInput , currentColor ] )
214+
215+ const handleHexSubmit = useCallback ( ( ) => {
216+ if ( ! canSubmitHex || ! onColorChange ) return
217+
218+ const normalized = normalizeHex ( hexInput )
219+ onColorChange ( normalized )
220+ setHexInput ( normalized )
221+ } , [ hexInput , canSubmitHex , onColorChange ] )
222+
223+ const handleHexKeyDown = useCallback (
224+ ( e : React . KeyboardEvent < HTMLInputElement > ) => {
225+ if ( e . key === 'Enter' ) {
226+ e . preventDefault ( )
227+ handleHexSubmit ( )
228+ }
229+ } ,
230+ [ handleHexSubmit ]
231+ )
232+
233+ const handleHexChange = useCallback ( ( e : React . ChangeEvent < HTMLInputElement > ) => {
234+ let value = e . target . value . trim ( )
235+ if ( value && ! value . startsWith ( '#' ) ) {
236+ value = `#${ value } `
237+ }
238+ value = value . slice ( 0 , 1 ) + value . slice ( 1 ) . replace ( / [ ^ 0 - 9 a - f A - F ] / g, '' )
239+ setHexInput ( value . slice ( 0 , 7 ) )
240+ } , [ ] )
241+
242+ const handleHexFocus = useCallback ( ( e : React . FocusEvent < HTMLInputElement > ) => {
243+ e . target . select ( )
244+ } , [ ] )
245+
177246 const hasNavigationSection = showOpenInNewTab && onOpenInNewTab
178247 const hasEditSection =
179248 ( showRename && onRename ) ||
@@ -268,23 +337,56 @@ export function ContextMenu({
268337 expandOnHover
269338 className = { disableColorChange ? 'pointer-events-none opacity-50' : '' }
270339 >
271- < div className = 'grid grid-cols-6 gap-[4px] p-[2px]' >
272- { WORKFLOW_COLORS . map ( ( { color, name } ) => (
340+ < div className = 'flex w-[140px] flex-col gap-[8px] p-[2px]' >
341+ { /* Preset colors */ }
342+ < div className = 'grid grid-cols-6 gap-[4px]' >
343+ { WORKFLOW_COLORS . map ( ( { color, name } ) => (
344+ < button
345+ key = { color }
346+ type = 'button'
347+ title = { name }
348+ onClick = { ( e ) => {
349+ e . stopPropagation ( )
350+ onColorChange ( color )
351+ } }
352+ className = { cn (
353+ 'h-[20px] w-[20px] rounded-[4px]' ,
354+ currentColor ?. toLowerCase ( ) === color . toLowerCase ( ) && 'ring-1 ring-white'
355+ ) }
356+ style = { { backgroundColor : color } }
357+ />
358+ ) ) }
359+ </ div >
360+
361+ { /* Hex input */ }
362+ < div className = 'flex items-center gap-[4px]' >
363+ < div
364+ className = 'h-[20px] w-[20px] flex-shrink-0 rounded-[4px]'
365+ style = { {
366+ backgroundColor : isValidHex ( hexInput ) ? normalizeHex ( hexInput ) : '#ffffff' ,
367+ } }
368+ />
369+ < input
370+ type = 'text'
371+ value = { hexInput }
372+ onChange = { handleHexChange }
373+ onKeyDown = { handleHexKeyDown }
374+ onFocus = { handleHexFocus }
375+ onClick = { ( e ) => e . stopPropagation ( ) }
376+ className = 'h-[20px] min-w-0 flex-1 rounded-[4px] bg-[#363636] px-[6px] text-[11px] text-white uppercase focus:outline-none'
377+ />
273378 < button
274- key = { color }
275379 type = 'button'
276- title = { name }
380+ disabled = { ! canSubmitHex }
277381 onClick = { ( e ) => {
278382 e . stopPropagation ( )
279- onColorChange ( color )
383+ handleHexSubmit ( )
280384 } }
281- className = { cn (
282- 'h-[20px] w-[20px] rounded-[4px]' ,
283- currentColor === color && 'ring-1 ring-white'
284- ) }
285- style = { { backgroundColor : color } }
286- />
287- ) ) }
385+ className = 'flex h-[20px] w-[20px] flex-shrink-0 items-center justify-center rounded-[4px] bg-[var(--brand-tertiary-2)] text-white disabled:opacity-40'
386+ >
387+ < Check className = 'h-[12px] w-[12px]' />
388+ </ button >
389+ </ div >
288390 </ div >
289391 </ PopoverFolder >
290392 ) }
0 commit comments