feat: status color picker — assign persistent color per status value (#120)

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 <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
This commit is contained in:
Luca Rossi
2026-02-27 15:27:43 +01:00
committed by GitHub
parent 4e6bbf4b88
commit 47acd1c30d
7 changed files with 1084 additions and 158 deletions

View File

@@ -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"
}
}
}

View File

@@ -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 (
<span className="relative inline-flex min-w-0 items-center">
<span
@@ -88,6 +90,7 @@ function StatusValue({ propKey, value, isEditing, vaultStatuses, onSave, onStart
vaultStatuses={vaultStatuses}
onSave={(newValue) => onSave(propKey, newValue)}
onCancel={() => onStartEdit(null)}
onColorChange={bumpColorVersion}
/>
)}
</span>

View File

@@ -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<string, string> = {}
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(<StatusPill status="Active" />)
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(<StatusPill status="Active" />)
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(<StatusDropdown {...defaultProps} />)
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(<StatusDropdown {...defaultProps} />)
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(<StatusDropdown {...defaultProps} />)
fireEvent.click(screen.getByTestId('color-dot-Draft'))
expect(onSave).not.toHaveBeenCalled()
})
it('closes palette when same dot is clicked again', () => {
render(<StatusDropdown {...defaultProps} />)
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(<StatusDropdown {...defaultProps} />)
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(<StatusDropdown {...defaultProps} />)
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(<StatusDropdown {...defaultProps} />)
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(<StatusDropdown {...defaultProps} />)
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()
})
})

View File

@@ -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 (
<button
className="flex w-full items-center border-none bg-transparent px-2 py-1 text-left transition-colors"
style={{
borderRadius: 4,
backgroundColor: highlighted ? 'var(--muted)' : 'transparent',
}}
onClick={() => onSelect(status)}
onMouseEnter={onMouseEnter}
data-testid={`status-option-${status}`}
className="flex shrink-0 items-center justify-center rounded-full border-none bg-transparent p-0 transition-transform hover:scale-125"
style={{ width: 12, height: 12 }}
onClick={onClick}
title="Change color"
data-testid={`color-dot-${status}`}
>
<StatusPill status={status} />
<span className="block rounded-full" style={{ width: 10, height: 10, backgroundColor: style.color }} />
</button>
)
}
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<HTMLInputElement>(null)
const listRef = useRef<HTMLDivElement>(null)
const currentKey = getStatusColorKey(status)
return (
<div
className="flex items-center gap-1.5 px-2 py-1.5"
style={{ backgroundColor: 'var(--muted)', borderRadius: 4 }}
data-testid={`color-palette-${status}`}
>
<button
className="shrink-0 cursor-pointer border bg-transparent px-1.5 py-0.5 text-[10px] transition-colors hover:bg-background"
style={{
borderColor: 'var(--border)', borderRadius: 10,
color: 'var(--muted-foreground)', fontWeight: currentKey === null ? 600 : 400,
}}
onClick={() => onSelect(status, null)}
data-testid={`color-swatch-default-${status}`}
>
Default
</button>
{ACCENT_COLORS.map(ac => (
<button
key={ac.key}
className="flex shrink-0 cursor-pointer items-center justify-center rounded-full border-none bg-transparent p-0 transition-transform hover:scale-125"
style={{ width: 16, height: 16 }}
onClick={() => onSelect(status, ac.key)}
title={ac.label}
data-testid={`color-swatch-${ac.key}-${status}`}
>
<span
className="block rounded-full"
style={{
width: 14, height: 14, backgroundColor: ac.css,
outline: currentKey === ac.key ? '2px solid var(--foreground)' : 'none',
outlineOffset: 1,
}}
/>
</button>
))}
</div>
)
}
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 (
<div>
<button
className="flex w-full items-center gap-1.5 border-none bg-transparent px-2 py-1 text-left transition-colors"
style={{ borderRadius: 4, backgroundColor: highlighted ? 'var(--muted)' : 'transparent' }}
onClick={() => onSelect(status)}
onMouseEnter={onMouseEnter}
data-testid={`status-option-${status}`}
>
<ColorDot status={status} onClick={(e) => { e.stopPropagation(); onDotClick(status) }} />
<StatusPill status={status} />
</button>
{pickerOpen && <ColorPalette status={status} onSelect={onColorSelect} />}
</div>
)
}
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 (
<div>
<div className="px-2 py-1">
<span className="text-muted-foreground" style={SECTION_LABEL_STYLE}>{label}</span>
</div>
{statuses.map((status, i) => (
<StatusOption
key={status} status={status}
highlighted={highlightIndex === highlightOffset + i}
pickerOpen={pickerStatus === status}
onSelect={onSave}
onMouseEnter={() => setHighlightIndex(highlightOffset + i)}
onDotClick={onDotClick} onColorSelect={onColorSelect}
/>
))}
</div>
)
}
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<string | null>(null)
const inputRef = useRef<HTMLInputElement>(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<HTMLInputElement>) => {
setQuery(e.target.value)
setHighlightIndex(-1)
}, [])
const sectionProps = {
highlightIndex, pickerStatus, onSave, setHighlightIndex,
onDotClick: handleDotClick, onColorSelect: handleColorSelect,
}
return (
<div className="relative" data-testid="status-dropdown">
{/* Backdrop to close on outside click */}
<div className="fixed inset-0 z-40" onClick={onCancel} data-testid="status-dropdown-backdrop" />
<div
className="absolute right-0 top-full z-50 mt-1 w-52 overflow-hidden rounded-lg border border-border bg-background shadow-lg"
data-testid="status-dropdown-popover"
>
{/* Search input */}
<div className="border-b border-border px-2 py-1.5">
<input
ref={inputRef}
className="w-full border-none bg-transparent text-[12px] text-foreground outline-none placeholder:text-muted-foreground"
placeholder="Type a status..."
value={query}
onChange={e => {
setQuery(e.target.value)
setHighlightIndex(-1)
}}
onKeyDown={handleKeyDown}
value={query} onChange={handleQueryChange} onKeyDown={handleKeyDown}
data-testid="status-search-input"
/>
</div>
{/* Options list */}
<div ref={listRef} className="max-h-52 overflow-y-auto py-1">
{vaultFiltered.length > 0 && (
<div>
<div className="px-2 py-1">
<span
className="text-muted-foreground"
style={{
fontFamily: "'IBM Plex Mono', monospace",
fontSize: 9,
fontWeight: 500,
letterSpacing: '1.2px',
textTransform: 'uppercase' as const,
}}
>
From vault
</span>
</div>
{vaultFiltered.map((status, i) => (
<StatusOption
key={status}
status={status}
highlighted={highlightIndex === i}
onSelect={onSave}
onMouseEnter={() => setHighlightIndex(i)}
/>
))}
</div>
)}
{suggestedFiltered.length > 0 && (
<div>
{vaultFiltered.length > 0 && (
<div className="my-1 h-px bg-border" />
)}
<div className="px-2 py-1">
<span
className="text-muted-foreground"
style={{
fontFamily: "'IBM Plex Mono', monospace",
fontSize: 9,
fontWeight: 500,
letterSpacing: '1.2px',
textTransform: 'uppercase' as const,
}}
>
Suggested
</span>
</div>
{suggestedFiltered.map((status, i) => (
<StatusOption
key={status}
status={status}
highlighted={highlightIndex === vaultFiltered.length + i}
onSelect={onSave}
onMouseEnter={() => setHighlightIndex(vaultFiltered.length + i)}
/>
))}
</div>
)}
<StatusSection label="From vault" statuses={vaultFiltered} highlightOffset={0} {...sectionProps} />
{vaultFiltered.length > 0 && suggestedFiltered.length > 0 && <div className="my-1 h-px bg-border" />}
<StatusSection label="Suggested" statuses={suggestedFiltered} highlightOffset={vaultFiltered.length} {...sectionProps} />
{showCreateOption && (
<>
{allFiltered.length > 0 && <div className="my-1 h-px bg-border" />}
@@ -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({
</button>
</>
)}
{allFiltered.length === 0 && !showCreateOption && (
<div className="px-2 py-2 text-center text-[11px] text-muted-foreground">
No matching statuses
</div>
<div className="px-2 py-2 text-center text-[11px] text-muted-foreground">No matching statuses</div>
)}
</div>
</div>

View File

@@ -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<HTMLDivElement>(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 }
}

View File

@@ -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<string, string> = {}
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'])
})
})

View File

@@ -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<string, StatusStyle> = Object.fromEntries(
ACCENT_COLORS.map(c => [c.key, { bg: c.cssLight, color: c.css }]),
)
const colorOverrides: Record<string, string> = loadColorOverrides()
function loadColorOverrides(): Record<string, string> {
try {
const raw = localStorage.getItem(STORAGE_KEY)
return raw ? (JSON.parse(raw) as Record<string, string>) : {}
} catch {
return {}
}
}
export function getStatusColorOverrides(): Record<string, string> {
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
}