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:
619
design/status-color-picker.pen
Normal file
619
design/status-color-picker.pen
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
48
src/hooks/useDropdownKeyboard.ts
Normal file
48
src/hooks/useDropdownKeyboard.ts
Normal 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 }
|
||||
}
|
||||
91
src/utils/statusStyles.test.ts
Normal file
91
src/utils/statusStyles.test.ts
Normal 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'])
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user