From 47acd1c30d95c86e39b411458ffe061189925eeb Mon Sep 17 00:00:00 2001 From: Luca Rossi Date: Fri, 27 Feb 2026 15:27:43 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20status=20color=20picker=20=E2=80=94=20a?= =?UTF-8?q?ssign=20persistent=20color=20per=20status=20value=20(#120)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add color dot indicators to each status option in the dropdown. Clicking the dot opens an inline palette of 8 accent colors (from the design system). Color assignments persist in localStorage and override the built-in defaults. Co-authored-by: Claude Opus 4.6 Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- design/status-color-picker.pen | 619 ++++++++++++++++++++++ src/components/DynamicPropertiesPanel.tsx | 3 + src/components/StatusDropdown.test.tsx | 126 ++++- src/components/StatusDropdown.tsx | 312 ++++++----- src/hooks/useDropdownKeyboard.ts | 48 ++ src/utils/statusStyles.test.ts | 91 ++++ src/utils/statusStyles.ts | 43 ++ 7 files changed, 1084 insertions(+), 158 deletions(-) create mode 100644 design/status-color-picker.pen create mode 100644 src/hooks/useDropdownKeyboard.ts create mode 100644 src/utils/statusStyles.test.ts diff --git a/design/status-color-picker.pen b/design/status-color-picker.pen new file mode 100644 index 00000000..e4ec159e --- /dev/null +++ b/design/status-color-picker.pen @@ -0,0 +1,619 @@ +{ + "children": [ + { + "children": [ + { + "content": "Status Dropdown — Color Dots", + "fill": "$--foreground", + "fontFamily": "Inter", + "fontSize": 16, + "fontWeight": "600", + "id": "1wdqT", + "name": "title1", + "type": "text" + }, + { + "content": "Each status option shows a color dot on the left. Clicking the dot opens the color palette picker.", + "fill": "$--muted-foreground", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal", + "id": "PsCXR", + "lineHeight": 1.5, + "name": "desc1", + "textGrowth": "fixed-width", + "type": "text", + "width": 272 + }, + { + "children": [ + { + "children": [ + { + "content": "Type a status...", + "fill": "$--muted-foreground", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal", + "id": "VyDsY", + "name": "searchText", + "type": "text" + } + ], + "id": "tim0g", + "name": "searchBar", + "padding": [ + 8, + 10 + ], + "stroke": { + "fill": "$--border", + "thickness": { + "bottom": 1 + } + }, + "type": "frame", + "width": "fill_container" + }, + { + "children": [ + { + "content": "FROM VAULT", + "fill": "$--muted-foreground", + "fontFamily": "IBM Plex Mono", + "fontSize": 9, + "fontWeight": "500", + "id": "Bn3lU", + "letterSpacing": 1.2, + "name": "labelText", + "type": "text" + } + ], + "id": "CB90c", + "name": "sectionLabel", + "padding": [ + 4, + 10 + ], + "type": "frame", + "width": "fill_container" + }, + { + "alignItems": "center", + "children": [ + { + "fill": "$--accent-green", + "height": 10, + "id": "8uSm6", + "name": "dot1", + "type": "ellipse", + "width": 10 + }, + { + "children": [ + { + "content": "ACTIVE", + "fill": "$--accent-green", + "fontFamily": "IBM Plex Mono", + "fontSize": 10, + "fontWeight": "600", + "id": "h4OER", + "letterSpacing": 1.2, + "name": "pill1text", + "type": "text" + } + ], + "cornerRadius": 16, + "fill": "$--accent-green-light", + "id": "k7sSo", + "name": "pill1", + "padding": [ + 1, + 6 + ], + "type": "frame" + } + ], + "cornerRadius": 4, + "gap": 8, + "id": "IZrMP", + "name": "Status Option - Active", + "padding": [ + 5, + 10 + ], + "type": "frame", + "width": "fill_container" + }, + { + "alignItems": "center", + "children": [ + { + "fill": "$--accent-red", + "height": 10, + "id": "sSEfx", + "name": "dot2", + "type": "ellipse", + "width": 10 + }, + { + "children": [ + { + "content": "BLOCKED", + "fill": "$--accent-red", + "fontFamily": "IBM Plex Mono", + "fontSize": 10, + "fontWeight": "600", + "id": "61jBJ", + "letterSpacing": 1.2, + "name": "pill2text", + "type": "text" + } + ], + "cornerRadius": 16, + "fill": "$--accent-red-light", + "id": "ozLQM", + "name": "pill2", + "padding": [ + 1, + 6 + ], + "type": "frame" + } + ], + "cornerRadius": 4, + "gap": 8, + "id": "B3Im8", + "name": "Status Option - Blocked", + "padding": [ + 5, + 10 + ], + "type": "frame", + "width": "fill_container" + }, + { + "alignItems": "center", + "children": [ + { + "fill": "$--accent-yellow", + "height": 10, + "id": "2mTmp", + "name": "dot3", + "type": "ellipse", + "width": 10 + }, + { + "children": [ + { + "content": "DRAFT", + "fill": "$--accent-yellow", + "fontFamily": "IBM Plex Mono", + "fontSize": 10, + "fontWeight": "600", + "id": "evl7l", + "letterSpacing": 1.2, + "name": "pill3text", + "type": "text" + } + ], + "cornerRadius": 16, + "fill": "$--accent-yellow-light", + "id": "6Fuxw", + "name": "pill3", + "padding": [ + 1, + 6 + ], + "type": "frame" + } + ], + "cornerRadius": 4, + "fill": "$--muted", + "gap": 8, + "id": "TwUBA", + "name": "Status Option - Draft (hover)", + "padding": [ + 5, + 10 + ], + "type": "frame", + "width": "fill_container" + } + ], + "cornerRadius": 8, + "fill": "$--background", + "id": "DT2gn", + "layout": "vertical", + "name": "Dropdown Container", + "stroke": { + "fill": "$--border", + "thickness": 1 + }, + "type": "frame", + "width": "fill_container" + } + ], + "fill": "$--background", + "gap": 16, + "id": "2N2BP", + "layout": "vertical", + "name": "1 — Status Dropdown with Color Dots", + "padding": 24, + "type": "frame", + "width": 320, + "x": 0, + "y": 0 + }, + { + "children": [ + { + "content": "Color Picker — Expanded", + "fill": "$--foreground", + "fontFamily": "Inter", + "fontSize": 16, + "fontWeight": "600", + "id": "zPCnI", + "name": "title2", + "type": "text" + }, + { + "content": "Clicking a color dot expands the palette inline. The 8 accent colors from the design system are shown as clickable swatches. A 'Default' option resets to the built-in color.", + "fill": "$--muted-foreground", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal", + "id": "TtaTt", + "lineHeight": 1.5, + "name": "desc2", + "textGrowth": "fixed-width", + "type": "text", + "width": 272 + }, + { + "children": [ + { + "alignItems": "center", + "children": [ + { + "fill": "$--accent-yellow", + "height": 10, + "id": "FRKg5", + "name": "dotActive", + "stroke": { + "fill": "$--foreground", + "thickness": 2 + }, + "type": "ellipse", + "width": 10 + }, + { + "children": [ + { + "content": "DRAFT", + "fill": "$--accent-yellow", + "fontFamily": "IBM Plex Mono", + "fontSize": 10, + "fontWeight": "600", + "id": "rA60a", + "letterSpacing": 1.2, + "name": "pillActiveText", + "type": "text" + } + ], + "cornerRadius": 16, + "fill": "$--accent-yellow-light", + "id": "15D2Q", + "name": "pillActive", + "padding": [ + 1, + 6 + ], + "type": "frame" + } + ], + "cornerRadius": 4, + "fill": "$--muted", + "gap": 8, + "id": "3QplJ", + "name": "Draft Option Row", + "padding": [ + 5, + 10 + ], + "type": "frame", + "width": "fill_container" + }, + { + "children": [ + { + "content": "ASSIGN COLOR", + "fill": "$--muted-foreground", + "fontFamily": "IBM Plex Mono", + "fontSize": 9, + "fontWeight": "500", + "id": "5yhS8", + "letterSpacing": 1.2, + "name": "paletteLabel", + "type": "text" + }, + { + "alignItems": "center", + "children": [ + { + "children": [ + { + "content": "Default", + "fill": "$--muted-foreground", + "fontFamily": "Inter", + "fontSize": 10, + "fontWeight": "normal", + "id": "Q2ZwX", + "name": "defText", + "type": "text" + } + ], + "cornerRadius": 10, + "id": "Sgzjz", + "name": "defBtn", + "padding": [ + 2, + 6 + ], + "stroke": { + "fill": "$--border", + "thickness": 1 + }, + "type": "frame" + }, + { + "fill": "$--accent-red", + "height": 16, + "id": "sOgl3", + "name": "sw1", + "type": "ellipse", + "width": 16 + }, + { + "fill": "$--accent-orange", + "height": 16, + "id": "kqj6G", + "name": "sw2", + "type": "ellipse", + "width": 16 + }, + { + "fill": "$--accent-yellow", + "height": 16, + "id": "luCEG", + "name": "sw3", + "type": "ellipse", + "width": 16 + }, + { + "fill": "$--accent-green", + "height": 16, + "id": "KmBPE", + "name": "sw4", + "type": "ellipse", + "width": 16 + }, + { + "fill": "$--accent-blue", + "height": 16, + "id": "IsLDd", + "name": "sw5", + "type": "ellipse", + "width": 16 + }, + { + "fill": "$--accent-purple", + "height": 16, + "id": "M6bwU", + "name": "sw6", + "type": "ellipse", + "width": 16 + }, + { + "fill": "$--accent-teal", + "height": 16, + "id": "LKPIO", + "name": "sw7", + "type": "ellipse", + "width": 16 + }, + { + "fill": "$--accent-pink", + "height": 16, + "id": "bFQCa", + "name": "sw8", + "type": "ellipse", + "width": 16 + } + ], + "gap": 6, + "id": "zwiKV", + "name": "Swatches Row", + "type": "frame" + } + ], + "fill": "$--muted", + "gap": 8, + "id": "ZUL7a", + "layout": "vertical", + "name": "Color Palette", + "padding": [ + 8, + 10 + ], + "type": "frame", + "width": "fill_container" + }, + { + "fill": "$--border", + "height": 1, + "id": "hsnvv", + "name": "divider", + "type": "rectangle", + "width": "fill_container" + }, + { + "alignItems": "center", + "children": [ + { + "fill": "$--accent-green", + "height": 10, + "id": "Mddih", + "name": "otherDot", + "type": "ellipse", + "width": 10 + }, + { + "children": [ + { + "content": "ACTIVE", + "fill": "$--accent-green", + "fontFamily": "IBM Plex Mono", + "fontSize": 10, + "fontWeight": "600", + "id": "pVJ5Z", + "letterSpacing": 1.2, + "name": "otherText", + "type": "text" + } + ], + "cornerRadius": 16, + "fill": "$--accent-green-light", + "id": "bHWx0", + "name": "otherPill", + "padding": [ + 1, + 6 + ], + "type": "frame" + } + ], + "cornerRadius": 4, + "gap": 8, + "id": "EpnwC", + "name": "Other Option", + "padding": [ + 5, + 10 + ], + "type": "frame", + "width": "fill_container" + } + ], + "cornerRadius": 8, + "fill": "$--background", + "id": "f2Adq", + "layout": "vertical", + "name": "Dropdown with Picker", + "stroke": { + "fill": "$--border", + "thickness": 1 + }, + "type": "frame", + "width": "fill_container" + } + ], + "fill": "$--background", + "gap": 16, + "id": "jbCuz", + "layout": "vertical", + "name": "2 — Color Picker Expanded", + "padding": 24, + "type": "frame", + "width": 320, + "x": 360, + "y": 0 + } + ], + "variables": { + "--accent-blue": { + "type": "color", + "value": "#155DFF" + }, + "--accent-blue-light": { + "type": "color", + "value": "#155DFF14" + }, + "--accent-green": { + "type": "color", + "value": "#38A169" + }, + "--accent-green-light": { + "type": "color", + "value": "#38A16919" + }, + "--accent-orange": { + "type": "color", + "value": "#D9730D" + }, + "--accent-orange-light": { + "type": "color", + "value": "#D9730D19" + }, + "--accent-pink": { + "type": "color", + "value": "#D53F8C" + }, + "--accent-pink-light": { + "type": "color", + "value": "#D53F8C19" + }, + "--accent-purple": { + "type": "color", + "value": "#805AD5" + }, + "--accent-purple-light": { + "type": "color", + "value": "#805AD519" + }, + "--accent-red": { + "type": "color", + "value": "#E53E3E" + }, + "--accent-red-light": { + "type": "color", + "value": "#E53E3E19" + }, + "--accent-teal": { + "type": "color", + "value": "#319795" + }, + "--accent-teal-light": { + "type": "color", + "value": "#31979519" + }, + "--accent-yellow": { + "type": "color", + "value": "#D69E2E" + }, + "--accent-yellow-light": { + "type": "color", + "value": "#D69E2E19" + }, + "--background": { + "type": "color", + "value": "#FFFFFF" + }, + "--border": { + "type": "color", + "value": "#E9E9E7" + }, + "--foreground": { + "type": "color", + "value": "#37352F" + }, + "--muted": { + "type": "color", + "value": "#F5F5F4" + }, + "--muted-foreground": { + "type": "color", + "value": "#787774" + } + } +} \ No newline at end of file diff --git a/src/components/DynamicPropertiesPanel.tsx b/src/components/DynamicPropertiesPanel.tsx index e30d54bb..7784f2bd 100644 --- a/src/components/DynamicPropertiesPanel.tsx +++ b/src/components/DynamicPropertiesPanel.tsx @@ -73,6 +73,8 @@ function StatusValue({ propKey, value, isEditing, vaultStatuses, onSave, onStart onSave: (key: string, value: string) => void; onStartEdit: (key: string | null) => void }) { const statusStr = String(value) + const [, setColorVersion] = useState(0) + const bumpColorVersion = useCallback(() => setColorVersion(v => v + 1), []) return ( onSave(propKey, newValue)} onCancel={() => onStartEdit(null)} + onColorChange={bumpColorVersion} /> )} diff --git a/src/components/StatusDropdown.test.tsx b/src/components/StatusDropdown.test.tsx index 0f3f91b4..7bf9e36b 100644 --- a/src/components/StatusDropdown.test.tsx +++ b/src/components/StatusDropdown.test.tsx @@ -1,8 +1,32 @@ -import { describe, it, expect, vi } from 'vitest' +import { describe, it, expect, vi, beforeEach } from 'vitest' import { render, screen, fireEvent } from '@testing-library/react' import { StatusPill, StatusDropdown } from './StatusDropdown' +import { setStatusColor } from '../utils/statusStyles' + +// Mock localStorage (jsdom's may be incomplete) +const localStorageMock = (() => { + let store: Record = {} + return { + getItem: (key: string) => store[key] ?? null, + setItem: (key: string, value: string) => { store[key] = value }, + removeItem: (key: string) => { delete store[key] }, + clear: () => { store = {} }, + get length() { return Object.keys(store).length }, + key: (i: number) => Object.keys(store)[i] ?? null, + } +})() +Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock, writable: true }) + +const STORAGE_KEY = 'laputa:status-color-overrides' describe('StatusPill', () => { + beforeEach(() => { + localStorageMock.clear() + // Clear any module-level overrides + setStatusColor('Active', null) + setStatusColor('Custom Thing', null) + }) + it('renders with known status style', () => { render() const pill = screen.getByTitle('Active') @@ -24,6 +48,15 @@ describe('StatusPill', () => { const pill = screen.getByTitle('Very Long Status Name That Should Truncate') expect(pill.className).toContain('truncate') }) + + it('renders with overridden color when set', () => { + setStatusColor('Active', 'pink') + render() + const pill = screen.getByTitle('Active') + expect(pill.style.backgroundColor).toBe('var(--accent-pink-light)') + expect(pill.style.color).toBe('var(--accent-pink)') + setStatusColor('Active', null) + }) }) describe('StatusDropdown', () => { @@ -39,6 +72,7 @@ describe('StatusDropdown', () => { beforeEach(() => { vi.clearAllMocks() + localStorageMock.clear() }) it('renders dropdown with search input', () => { @@ -159,3 +193,93 @@ describe('StatusDropdown', () => { expect(onSave).toHaveBeenCalledWith('wIP') }) }) + +describe('StatusDropdown — color picker', () => { + const onSave = vi.fn() + const onCancel = vi.fn() + const onColorChange = vi.fn() + + const defaultProps = { + value: 'Active', + vaultStatuses: ['Draft', 'Published'], + onSave, + onCancel, + onColorChange, + } + + beforeEach(() => { + vi.clearAllMocks() + localStorageMock.clear() + setStatusColor('Draft', null) + setStatusColor('Active', null) + }) + + it('renders color dots for each status option', () => { + render() + expect(screen.getByTestId('color-dot-Draft')).toBeInTheDocument() + expect(screen.getByTestId('color-dot-Published')).toBeInTheDocument() + expect(screen.getByTestId('color-dot-Active')).toBeInTheDocument() + }) + + it('opens color palette when color dot is clicked', () => { + render() + fireEvent.click(screen.getByTestId('color-dot-Draft')) + expect(screen.getByTestId('color-palette-Draft')).toBeInTheDocument() + }) + + it('does not trigger onSave when color dot is clicked', () => { + render() + fireEvent.click(screen.getByTestId('color-dot-Draft')) + expect(onSave).not.toHaveBeenCalled() + }) + + it('closes palette when same dot is clicked again', () => { + render() + fireEvent.click(screen.getByTestId('color-dot-Draft')) + expect(screen.getByTestId('color-palette-Draft')).toBeInTheDocument() + fireEvent.click(screen.getByTestId('color-dot-Draft')) + expect(screen.queryByTestId('color-palette-Draft')).not.toBeInTheDocument() + }) + + it('assigns a color when swatch is clicked', () => { + render() + fireEvent.click(screen.getByTestId('color-dot-Draft')) + fireEvent.click(screen.getByTestId('color-swatch-red-Draft')) + expect(onColorChange).toHaveBeenCalled() + // Verify the color was persisted + const stored = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '{}') + expect(stored['Draft']).toBe('red') + }) + + it('clicking Default swatch resets color to built-in', () => { + setStatusColor('Draft', 'red') + render() + fireEvent.click(screen.getByTestId('color-dot-Draft')) + fireEvent.click(screen.getByTestId('color-swatch-default-Draft')) + expect(onColorChange).toHaveBeenCalled() + const stored = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '{}') + expect(stored['Draft']).toBeUndefined() + }) + + it('closes palette after selecting a color', () => { + render() + fireEvent.click(screen.getByTestId('color-dot-Draft')) + expect(screen.getByTestId('color-palette-Draft')).toBeInTheDocument() + fireEvent.click(screen.getByTestId('color-swatch-purple-Draft')) + expect(screen.queryByTestId('color-palette-Draft')).not.toBeInTheDocument() + }) + + it('shows all 8 accent color swatches plus default', () => { + render() + fireEvent.click(screen.getByTestId('color-dot-Draft')) + expect(screen.getByTestId('color-swatch-default-Draft')).toBeInTheDocument() + expect(screen.getByTestId('color-swatch-red-Draft')).toBeInTheDocument() + expect(screen.getByTestId('color-swatch-orange-Draft')).toBeInTheDocument() + expect(screen.getByTestId('color-swatch-yellow-Draft')).toBeInTheDocument() + expect(screen.getByTestId('color-swatch-green-Draft')).toBeInTheDocument() + expect(screen.getByTestId('color-swatch-blue-Draft')).toBeInTheDocument() + expect(screen.getByTestId('color-swatch-purple-Draft')).toBeInTheDocument() + expect(screen.getByTestId('color-swatch-teal-Draft')).toBeInTheDocument() + expect(screen.getByTestId('color-swatch-pink-Draft')).toBeInTheDocument() + }) +}) diff --git a/src/components/StatusDropdown.tsx b/src/components/StatusDropdown.tsx index 7bd9c2db..c8d07f9d 100644 --- a/src/components/StatusDropdown.tsx +++ b/src/components/StatusDropdown.tsx @@ -1,5 +1,7 @@ import { useState, useRef, useEffect, useCallback, useMemo } from 'react' -import { getStatusStyle, SUGGESTED_STATUSES } from '../utils/statusStyles' +import { getStatusStyle, getStatusColorKey, setStatusColor, SUGGESTED_STATUSES } from '../utils/statusStyles' +import { ACCENT_COLORS } from '../utils/typeColors' +import { useDropdownKeyboard } from '../hooks/useDropdownKeyboard' export function StatusPill({ status, className }: { status: string; className?: string }) { const style = getStatusStyle(status) @@ -25,199 +27,199 @@ export function StatusPill({ status, className }: { status: string; className?: ) } -function StatusOption({ - status, - highlighted, - onSelect, - onMouseEnter, -}: { - status: string - highlighted: boolean - onSelect: (status: string) => void - onMouseEnter: () => void -}) { +function ColorDot({ status, onClick }: { status: string; onClick: (e: React.MouseEvent) => void }) { + const style = getStatusStyle(status) return ( ) } -export function StatusDropdown({ - vaultStatuses, - onSave, - onCancel, -}: { - value: string - vaultStatuses: string[] - onSave: (newValue: string) => void - onCancel: () => void +function ColorPalette({ status, onSelect }: { + status: string; onSelect: (status: string, colorKey: string | null) => void }) { - const [query, setQuery] = useState('') - const [highlightIndex, setHighlightIndex] = useState(-1) - const inputRef = useRef(null) - const listRef = useRef(null) + const currentKey = getStatusColorKey(status) + return ( +
+ + {ACCENT_COLORS.map(ac => ( + + ))} +
+ ) +} - useEffect(() => { - inputRef.current?.focus() - }, []) +function StatusOption({ + status, highlighted, pickerOpen, onSelect, onMouseEnter, onDotClick, onColorSelect, +}: { + status: string; highlighted: boolean; pickerOpen: boolean + onSelect: (status: string) => void; onMouseEnter: () => void + onDotClick: (status: string) => void; onColorSelect: (status: string, colorKey: string | null) => void +}) { + return ( +
+ + {pickerOpen && } +
+ ) +} - const { suggestedFiltered, vaultFiltered, allFiltered } = useMemo(() => { +const SECTION_LABEL_STYLE = { + fontFamily: "'IBM Plex Mono', monospace", fontSize: 9, fontWeight: 500, + letterSpacing: '1.2px', textTransform: 'uppercase' as const, +} as const + +function StatusSection({ label, statuses, highlightOffset, highlightIndex, pickerStatus, onSave, setHighlightIndex, onDotClick, onColorSelect }: { + label: string; statuses: string[]; highlightOffset: number; highlightIndex: number + pickerStatus: string | null + onSave: (v: string) => void; setHighlightIndex: (i: number) => void + onDotClick: (s: string) => void; onColorSelect: (s: string, k: string | null) => void +}) { + if (statuses.length === 0) return null + return ( +
+
+ {label} +
+ {statuses.map((status, i) => ( + setHighlightIndex(highlightOffset + i)} + onDotClick={onDotClick} onColorSelect={onColorSelect} + /> + ))} +
+ ) +} + +function useStatusFilter(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], - } + return { suggestedFiltered: suggested, vaultFiltered: vault, allFiltered: [...vault, ...suggested] } }, [query, vaultStatuses]) +} + +export function StatusDropdown({ + vaultStatuses, onSave, onCancel, onColorChange, +}: { + value: string; vaultStatuses: string[] + onSave: (newValue: string) => void; onCancel: () => void; onColorChange?: () => void +}) { + const [query, setQuery] = useState('') + const [highlightIndex, setHighlightIndex] = useState(-1) + const [pickerStatus, setPickerStatus] = useState(null) + const inputRef = useRef(null) + + useEffect(() => { inputRef.current?.focus() }, []) + + const { suggestedFiltered, vaultFiltered, allFiltered } = useStatusFilter(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 { listRef, handleKeyDown } = useDropdownKeyboard({ + highlightIndex, setHighlightIndex, totalOptions, + allFiltered, showCreateOption, query, onSave, onCancel, + }) + + const handleDotClick = useCallback((status: string) => { + setPickerStatus(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 handleColorSelect = useCallback((status: string, colorKey: string | null) => { + setStatusColor(status, colorKey) + setPickerStatus(null) + onColorChange?.() + }, [onColorChange]) + + const handleQueryChange = useCallback((e: React.ChangeEvent) => { + setQuery(e.target.value) + setHighlightIndex(-1) + }, []) + + const sectionProps = { + highlightIndex, pickerStatus, onSave, setHighlightIndex, + onDotClick: handleDotClick, onColorSelect: handleColorSelect, + } return (
- {/* Backdrop to close on outside click */}
-
- {/* Search input */}
{ - setQuery(e.target.value) - setHighlightIndex(-1) - }} - onKeyDown={handleKeyDown} + value={query} onChange={handleQueryChange} 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)} - /> - ))} -
- )} - + + {vaultFiltered.length > 0 && suggestedFiltered.length > 0 &&
} + {showCreateOption && ( <> {allFiltered.length > 0 &&
} @@ -225,8 +227,7 @@ export function StatusDropdown({ className="flex w-full items-center gap-1.5 border-none bg-transparent px-2 py-1 text-left text-[11px] transition-colors" style={{ borderRadius: 4, - backgroundColor: - highlightIndex === allFiltered.length ? 'var(--muted)' : 'transparent', + backgroundColor: highlightIndex === allFiltered.length ? 'var(--muted)' : 'transparent', color: 'var(--muted-foreground)', }} onClick={() => onSave(query.trim())} @@ -237,11 +238,8 @@ export function StatusDropdown({ )} - {allFiltered.length === 0 && !showCreateOption && ( -
- No matching statuses -
+
No matching statuses
)}
diff --git a/src/hooks/useDropdownKeyboard.ts b/src/hooks/useDropdownKeyboard.ts new file mode 100644 index 00000000..c02a81b0 --- /dev/null +++ b/src/hooks/useDropdownKeyboard.ts @@ -0,0 +1,48 @@ +import { useCallback, useRef } from 'react' + +function resolveEnterTarget( + highlightIndex: number, allFiltered: string[], showCreateOption: boolean, query: string, +): string | null { + if (highlightIndex >= 0 && highlightIndex < allFiltered.length) return allFiltered[highlightIndex] + if (showCreateOption && highlightIndex === allFiltered.length) return query.trim() + if (query.trim()) return query.trim() + return null +} + +export function useDropdownKeyboard({ + highlightIndex, setHighlightIndex, totalOptions, allFiltered, showCreateOption, query, onSave, onCancel, +}: { + highlightIndex: number; setHighlightIndex: (i: number) => void; totalOptions: number + allFiltered: string[]; showCreateOption: boolean; query: string + onSave: (v: string) => void; onCancel: () => void +}) { + const listRef = useRef(null) + + const scrollHighlightedIntoView = useCallback((index: number) => { + const items = listRef.current?.querySelectorAll('[data-testid^="status-option-"], [data-testid="status-create-option"]') + items?.[index]?.scrollIntoView({ block: 'nearest' }) + }, []) + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { + e.preventDefault() + const next = e.key === 'ArrowDown' + ? (highlightIndex < totalOptions - 1 ? highlightIndex + 1 : 0) + : (highlightIndex > 0 ? highlightIndex - 1 : totalOptions - 1) + setHighlightIndex(next) + scrollHighlightedIntoView(next) + } else if (e.key === 'Enter') { + e.preventDefault() + const target = resolveEnterTarget(highlightIndex, allFiltered, showCreateOption, query) + if (target) onSave(target) + } else if (e.key === 'Escape') { + e.preventDefault() + onCancel() + } + }, + [highlightIndex, totalOptions, allFiltered, showCreateOption, query, onSave, onCancel, setHighlightIndex, scrollHighlightedIntoView], + ) + + return { listRef, handleKeyDown } +} diff --git a/src/utils/statusStyles.test.ts b/src/utils/statusStyles.test.ts new file mode 100644 index 00000000..0ac9ba06 --- /dev/null +++ b/src/utils/statusStyles.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { + getStatusStyle, + getStatusColorKey, + setStatusColor, + getStatusColorOverrides, + STATUS_STYLES, + DEFAULT_STATUS_STYLE, +} from './statusStyles' + +// Mock localStorage (jsdom's may be incomplete) +const localStorageMock = (() => { + let store: Record = {} + return { + getItem: (key: string) => store[key] ?? null, + setItem: (key: string, value: string) => { store[key] = value }, + removeItem: (key: string) => { delete store[key] }, + clear: () => { store = {} }, + get length() { return Object.keys(store).length }, + key: (i: number) => Object.keys(store)[i] ?? null, + } +})() +Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock, writable: true }) + +const STORAGE_KEY = 'laputa:status-color-overrides' + +describe('statusStyles — color overrides', () => { + beforeEach(() => { + localStorageMock.clear() + // Reset module-level cache by clearing all overrides + for (const key of Object.keys(getStatusColorOverrides())) { + setStatusColor(key, null) + } + }) + + it('returns built-in style when no override exists', () => { + expect(getStatusStyle('Active')).toEqual(STATUS_STYLES['Active']) + }) + + it('returns default style for unknown status without override', () => { + expect(getStatusStyle('MyCustom')).toEqual(DEFAULT_STATUS_STYLE) + }) + + it('getStatusColorKey returns null when no override set', () => { + expect(getStatusColorKey('Active')).toBeNull() + }) + + it('setStatusColor persists a color override', () => { + setStatusColor('Active', 'red') + expect(getStatusColorKey('Active')).toBe('red') + expect(localStorage.getItem(STORAGE_KEY)).toContain('"Active":"red"') + }) + + it('getStatusStyle uses override when set', () => { + setStatusColor('Active', 'pink') + const style = getStatusStyle('Active') + expect(style.color).toBe('var(--accent-pink)') + expect(style.bg).toBe('var(--accent-pink-light)') + }) + + it('setStatusColor with null removes the override', () => { + setStatusColor('Active', 'red') + expect(getStatusColorKey('Active')).toBe('red') + setStatusColor('Active', null) + expect(getStatusColorKey('Active')).toBeNull() + expect(getStatusStyle('Active')).toEqual(STATUS_STYLES['Active']) + }) + + it('getStatusColorOverrides returns a copy of all overrides', () => { + setStatusColor('Draft', 'teal') + setStatusColor('Blocked', 'orange') + const overrides = getStatusColorOverrides() + expect(overrides).toEqual({ Draft: 'teal', Blocked: 'orange' }) + // Verify it's a copy, not a reference + overrides['Draft'] = 'blue' + expect(getStatusColorKey('Draft')).toBe('teal') + }) + + it('applies override to unknown status (not in STATUS_STYLES)', () => { + setStatusColor('Custom Status', 'purple') + const style = getStatusStyle('Custom Status') + expect(style.color).toBe('var(--accent-purple)') + expect(style.bg).toBe('var(--accent-purple-light)') + }) + + it('ignores invalid color key in override', () => { + setStatusColor('Active', 'nonexistent-color') + // Falls back to built-in since "nonexistent-color" isn't a valid ACCENT_COLOR key + expect(getStatusStyle('Active')).toEqual(STATUS_STYLES['Active']) + }) +}) diff --git a/src/utils/statusStyles.ts b/src/utils/statusStyles.ts index 99c7ac61..7cc4b7cd 100644 --- a/src/utils/statusStyles.ts +++ b/src/utils/statusStyles.ts @@ -1,3 +1,5 @@ +import { ACCENT_COLORS } from './typeColors' + export interface StatusStyle { bg: string color: string @@ -38,6 +40,47 @@ export const SUGGESTED_STATUSES = [ 'Archived', ] +const STORAGE_KEY = 'laputa:status-color-overrides' + +const COLOR_KEY_TO_STYLE: Record = Object.fromEntries( + ACCENT_COLORS.map(c => [c.key, { bg: c.cssLight, color: c.css }]), +) + +const colorOverrides: Record = loadColorOverrides() + +function loadColorOverrides(): Record { + try { + const raw = localStorage.getItem(STORAGE_KEY) + return raw ? (JSON.parse(raw) as Record) : {} + } catch { + return {} + } +} + +export function getStatusColorOverrides(): Record { + return { ...colorOverrides } +} + +export function setStatusColor(status: string, colorKey: string | null): void { + if (colorKey === null) { + delete colorOverrides[status] + } else { + colorOverrides[status] = colorKey + } + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(colorOverrides)) + } catch { /* storage full — silently ignore */ } +} + +export function getStatusColorKey(status: string): string | null { + return colorOverrides[status] ?? null +} + export function getStatusStyle(status: string): StatusStyle { + const overrideKey = colorOverrides[status] + if (overrideKey) { + const style = COLOR_KEY_TO_STYLE[overrideKey] + if (style) return style + } return STATUS_STYLES[status] ?? DEFAULT_STATUS_STYLE }