From 3d784ce740d0b1b10ffbd899590a7f2168dbd008 Mon Sep 17 00:00:00 2001 From: Luca Rossi Date: Fri, 27 Feb 2026 16:53:31 +0100 Subject: [PATCH] fix: unify status dropdown and make color swatch visible (#128) Bug 1: Replace plain-text Radix Select in AddStatusInput with the same StatusDropdown component used for existing status properties. Both now show colored pills, search, and custom status creation. Bug 2: Add color swatch dot next to each status option in the dropdown. Clicking it opens an inline palette of 8 accent colors that persist via localStorage (leveraging existing setStatusColor infrastructure). Refactor StatusDropdown to extract useStatusFiltering, useStatusKeyboard hooks and VaultSection/SuggestedSection/CreateSection sub-components to keep Code Health above 9.2 threshold. Co-authored-by: Test Co-authored-by: Claude Opus 4.6 --- src/components/DynamicPropertiesPanel.tsx | 31 +- src/components/StatusDropdown.tsx | 422 ++++++++++++++-------- 2 files changed, 282 insertions(+), 171 deletions(-) diff --git a/src/components/DynamicPropertiesPanel.tsx b/src/components/DynamicPropertiesPanel.tsx index 16d575a9..6d29cfb6 100644 --- a/src/components/DynamicPropertiesPanel.tsx +++ b/src/components/DynamicPropertiesPanel.tsx @@ -22,7 +22,6 @@ import { detectPropertyType, } from '../utils/propertyTypes' import { StatusPill, StatusDropdown } from './StatusDropdown' -import { SUGGESTED_STATUSES } from '../utils/statusStyles' // Keys that are relationships (contain wikilinks) export const RELATIONSHIP_KEYS = new Set([ @@ -286,23 +285,25 @@ function AddDateInput({ value, onChange }: { value: string; onChange: (v: string } function AddStatusInput({ value, onChange, vaultStatuses }: { value: string; onChange: (v: string) => void; vaultStatuses: string[] }) { - const allStatuses = [...new Set([...vaultStatuses, ...SUGGESTED_STATUSES])] + const [showDropdown, setShowDropdown] = useState(false) return ( - + {value ? : Status{'\u2026'}} + + {showDropdown && ( + { onChange(v); setShowDropdown(false) }} + onCancel={() => setShowDropdown(false)} + /> + )} + ) } diff --git a/src/components/StatusDropdown.tsx b/src/components/StatusDropdown.tsx index fc326e61..caafb5f2 100644 --- a/src/components/StatusDropdown.tsx +++ b/src/components/StatusDropdown.tsx @@ -1,5 +1,6 @@ import { useState, useRef, useEffect, useCallback, useMemo } from 'react' -import { getStatusStyle, SUGGESTED_STATUSES } from '../utils/statusStyles' +import { getStatusStyle, SUGGESTED_STATUSES, setStatusColor, getStatusColorKey } from '../utils/statusStyles' +import { ACCENT_COLORS } from '../utils/typeColors' export function StatusPill({ status, className }: { status: string; className?: string }) { const style = getStatusStyle(status) @@ -25,33 +26,193 @@ export function StatusPill({ status, className }: { status: string; className?: ) } +function ColorPickerRow({ status, onColorChange }: { status: string; onColorChange: (status: string, colorKey: string) => void }) { + const currentKey = getStatusColorKey(status) + return ( +
+ {ACCENT_COLORS.map(c => ( + + ))} +
+ ) +} + function StatusOption({ status, highlighted, onSelect, onMouseEnter, + colorEditing, + onToggleColor, + onColorChange, }: { status: string highlighted: boolean onSelect: (status: string) => void onMouseEnter: () => void + colorEditing: boolean + onToggleColor: (status: string) => void + onColorChange: (status: string, colorKey: string) => void }) { + const style = getStatusStyle(status) return ( - + <> +
+ +
+ {colorEditing && } + ) } +const SECTION_LABEL_STYLE = { + fontFamily: "'IBM Plex Mono', monospace", + fontSize: 9, + fontWeight: 500, + letterSpacing: '1.2px', + textTransform: 'uppercase' as const, +} + +function SectionLabel({ children }: { children: string }) { + return ( +
+ {children} +
+ ) +} + +interface StatusOptionProps { + highlightOffset: number + highlightIndex: number + colorEditingStatus: string | null + onSelect: (status: string) => void + onHighlight: (index: number) => void + onToggleColor: (status: string) => void + onColorChange: (status: string, colorKey: string) => void +} + +function StatusOptionList({ statuses, ...props }: StatusOptionProps & { statuses: string[] }) { + return ( + <> + {statuses.map((status, i) => ( + props.onHighlight(props.highlightOffset + i)} + colorEditing={props.colorEditingStatus === status} + onToggleColor={props.onToggleColor} + onColorChange={props.onColorChange} + /> + ))} + + ) +} + +function useStatusFiltering(query: string, vaultStatuses: string[]) { + return useMemo(() => { + const lowerQuery = query.toLowerCase() + const vaultSet = new Set(vaultStatuses.map(s => s.toLowerCase())) + const suggested = SUGGESTED_STATUSES.filter( + s => s.toLowerCase().includes(lowerQuery) && !vaultSet.has(s.toLowerCase()), + ) + const vault = vaultStatuses.filter(s => s.toLowerCase().includes(lowerQuery)) + return { suggestedFiltered: suggested, vaultFiltered: vault, allFiltered: [...vault, ...suggested] } + }, [query, vaultStatuses]) +} + +interface KeyboardNavOptions { + allFiltered: string[] + totalOptions: number + showCreateOption: boolean + query: string + onSave: (v: string) => void + onCancel: () => void + listRef: React.RefObject +} + +function useStatusKeyboard(opts: KeyboardNavOptions) { + const { allFiltered, totalOptions, showCreateOption, query, onSave, onCancel, listRef } = opts + const [highlightIndex, setHighlightIndex] = useState(-1) + + const scrollIntoView = useCallback((index: number) => { + const list = listRef.current + if (!list) return + const items = list.querySelectorAll('[data-testid^="status-option-"], [data-testid="status-create-option"]') + items[index]?.scrollIntoView({ block: 'nearest' }) + }, [listRef]) + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + switch (e.key) { + case 'ArrowDown': { + e.preventDefault() + const next = highlightIndex < totalOptions - 1 ? highlightIndex + 1 : 0 + setHighlightIndex(next) + scrollIntoView(next) + break + } + case 'ArrowUp': { + e.preventDefault() + const prev = highlightIndex > 0 ? highlightIndex - 1 : totalOptions - 1 + setHighlightIndex(prev) + scrollIntoView(prev) + break + } + case 'Enter': { + e.preventDefault() + const trimmed = query.trim() + if (highlightIndex >= 0 && highlightIndex < allFiltered.length) onSave(allFiltered[highlightIndex]) + else if (showCreateOption && highlightIndex === allFiltered.length) onSave(trimmed) + else if (trimmed) onSave(trimmed) + break + } + case 'Escape': + e.preventDefault() + onCancel() + break + } + }, + [highlightIndex, totalOptions, allFiltered, showCreateOption, query, onSave, onCancel, scrollIntoView], + ) + + const resetHighlight = useCallback(() => setHighlightIndex(-1), []) + + return { highlightIndex, setHighlightIndex, handleKeyDown, resetHighlight } +} + export function StatusDropdown({ vaultStatuses, onSave, @@ -63,181 +224,83 @@ export function StatusDropdown({ onCancel: () => void }) { const [query, setQuery] = useState('') - const [highlightIndex, setHighlightIndex] = useState(-1) + const [colorEditingStatus, setColorEditingStatus] = useState(null) const inputRef = useRef(null) const listRef = useRef(null) - useEffect(() => { - inputRef.current?.focus() - }, []) + useEffect(() => { inputRef.current?.focus() }, []) - const { suggestedFiltered, vaultFiltered, allFiltered } = useMemo(() => { - const lowerQuery = query.toLowerCase() - const vaultSet = new Set(vaultStatuses.map(s => s.toLowerCase())) - - const suggested = SUGGESTED_STATUSES.filter( - s => s.toLowerCase().includes(lowerQuery) && !vaultSet.has(s.toLowerCase()), - ) - - const vault = vaultStatuses.filter(s => s.toLowerCase().includes(lowerQuery)) - - return { - suggestedFiltered: suggested, - vaultFiltered: vault, - allFiltered: [...vault, ...suggested], - } - }, [query, vaultStatuses]) + const { suggestedFiltered, vaultFiltered, allFiltered } = useStatusFiltering(query, vaultStatuses) const showCreateOption = useMemo(() => { if (!query.trim()) return false - const lowerQuery = query.trim().toLowerCase() - return !allFiltered.some(s => s.toLowerCase() === lowerQuery) + return !allFiltered.some(s => s.toLowerCase() === query.trim().toLowerCase()) }, [query, allFiltered]) const totalOptions = allFiltered.length + (showCreateOption ? 1 : 0) - const scrollHighlightedIntoView = useCallback((index: number) => { - const list = listRef.current - if (!list) return - const items = list.querySelectorAll('[data-testid^="status-option-"], [data-testid="status-create-option"]') - items[index]?.scrollIntoView({ block: 'nearest' }) + const { highlightIndex, setHighlightIndex, handleKeyDown, resetHighlight } = + useStatusKeyboard({ allFiltered, totalOptions, showCreateOption, query, onSave, onCancel, listRef }) + + const handleToggleColor = useCallback((status: string) => { + setColorEditingStatus(prev => prev === status ? null : status) }, []) - const handleKeyDown = useCallback( - (e: React.KeyboardEvent) => { - if (e.key === 'ArrowDown') { - e.preventDefault() - const next = highlightIndex < totalOptions - 1 ? highlightIndex + 1 : 0 - setHighlightIndex(next) - scrollHighlightedIntoView(next) - } else if (e.key === 'ArrowUp') { - e.preventDefault() - const prev = highlightIndex > 0 ? highlightIndex - 1 : totalOptions - 1 - setHighlightIndex(prev) - scrollHighlightedIntoView(prev) - } else if (e.key === 'Enter') { - e.preventDefault() - if (highlightIndex >= 0 && highlightIndex < allFiltered.length) { - onSave(allFiltered[highlightIndex]) - } else if (showCreateOption && highlightIndex === allFiltered.length) { - onSave(query.trim()) - } else if (query.trim()) { - onSave(query.trim()) - } - } else if (e.key === 'Escape') { - e.preventDefault() - onCancel() - } - }, - [highlightIndex, totalOptions, allFiltered, showCreateOption, query, onSave, onCancel, scrollHighlightedIntoView], - ) + const handleColorChange = useCallback((status: string, colorKey: string) => { + const currentKey = getStatusColorKey(status) + setStatusColor(status, currentKey === colorKey ? null : colorKey) + setColorEditingStatus(null) + }, []) + + const handleQueryChange = useCallback((value: string) => { + setQuery(value) + resetHighlight() + }, [resetHighlight]) + + const optionProps: StatusOptionProps = { + highlightOffset: 0, + highlightIndex, + colorEditingStatus, + onSelect: onSave, + onHighlight: setHighlightIndex, + onToggleColor: handleToggleColor, + onColorChange: handleColorChange, + } return (
- {/* Backdrop to close on outside click */}
-
- {/* Search input */}
{ - setQuery(e.target.value) - setHighlightIndex(-1) - }} + onChange={e => handleQueryChange(e.target.value)} onKeyDown={handleKeyDown} data-testid="status-search-input" />
- - {/* Options list */}
- {vaultFiltered.length > 0 && ( -
-
- - From vault - -
- {vaultFiltered.map((status, i) => ( - setHighlightIndex(i)} - /> - ))} -
- )} - - {suggestedFiltered.length > 0 && ( -
- {vaultFiltered.length > 0 && ( -
- )} -
- - Suggested - -
- {suggestedFiltered.map((status, i) => ( - setHighlightIndex(vaultFiltered.length + i)} - /> - ))} -
- )} - - {showCreateOption && ( - <> - {allFiltered.length > 0 &&
} - - - )} - + + 0} + {...optionProps} + highlightOffset={vaultFiltered.length} + /> + 0} + highlighted={highlightIndex === allFiltered.length} + onSave={onSave} + onMouseEnter={() => setHighlightIndex(allFiltered.length)} + /> {allFiltered.length === 0 && !showCreateOption && (
No matching statuses @@ -248,3 +311,50 @@ export function StatusDropdown({
) } + +function VaultSection({ statuses, ...props }: StatusOptionProps & { statuses: string[] }) { + if (statuses.length === 0) return null + return ( +
+ From vault + +
+ ) +} + +function SuggestedSection({ statuses, showDivider, ...props }: StatusOptionProps & { statuses: string[]; showDivider: boolean }) { + if (statuses.length === 0) return null + return ( +
+ {showDivider &&
} + Suggested + +
+ ) +} + +function CreateSection({ show, query, showDivider, highlighted, onSave, onMouseEnter }: { + show: boolean; query: string; showDivider: boolean; highlighted: boolean + onSave: (v: string) => void; onMouseEnter: () => void +}) { + if (!show) return null + const trimmed = query.trim() + return ( + <> + {showDivider &&
} + + + ) +}