Files
tolaria/src/components/StatusDropdown.tsx
lucaronin 7019cd400b fix: replace monospaced ALL CAPS labels with Inter sentence case
Remove IBM Plex Mono + text-transform: uppercase from all UI labels.
CSS classes .font-mono-label and .font-mono-overline now use Inter
with no text-transform. Inline monospace+uppercase styles in
StatusDropdown, TagsDropdown, ReferencedByPanel, AiActionCard replaced.
Tailwind uppercase removed from Sidebar, CommandPalette, PulseView,
CreateNoteDialog, CreateTypeDialog. Hardcoded ALL CAPS text (COLOR,
ICON, TEMPLATE) converted to sentence case. Code blocks and commit
hashes remain monospaced.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 17:00:04 +02:00

382 lines
13 KiB
TypeScript

import { useState, useRef, useEffect, useLayoutEffect, useCallback, useMemo } from 'react'
import { createPortal } from 'react-dom'
import { getStatusStyle, SUGGESTED_STATUSES, setStatusColor, getStatusColorKey } from '../utils/statusStyles'
import { ACCENT_COLORS } from '../utils/typeColors'
export function StatusPill({ status, className }: { status: string; className?: string }) {
const style = getStatusStyle(status)
return (
<span
className={`inline-block min-w-0 truncate${className ? ` ${className}` : ''}`}
style={{
backgroundColor: style.bg,
color: style.color,
borderRadius: 16,
padding: '1px 6px',
fontFamily: "'Inter', sans-serif",
fontSize: 10,
fontWeight: 600,
letterSpacing: '0',
maxWidth: 160,
}}
title={status}
>
{status}
</span>
)
}
function ColorPickerRow({ status, onColorChange }: { status: string; onColorChange: (status: string, colorKey: string) => void }) {
const currentKey = getStatusColorKey(status)
return (
<div className="flex items-center gap-1 px-3 py-1.5" data-testid={`color-picker-${status}`}>
{ACCENT_COLORS.map(c => (
<button
key={c.key}
className="flex size-4 shrink-0 items-center justify-center rounded-full border-none p-0 transition-transform hover:scale-125"
style={{ backgroundColor: c.css }}
onClick={(e) => { e.stopPropagation(); onColorChange(status, c.key) }}
title={c.label}
data-testid={`color-option-${c.key}`}
>
{currentKey === c.key && (
<span style={{ color: 'white', fontSize: 8, lineHeight: 1 }}>{'\u2713'}</span>
)}
</button>
))}
</div>
)
}
function StatusOption({
status,
highlighted,
onSelect,
onMouseEnter,
colorEditing,
onToggleColor,
onColorChange,
}: {
status: string
highlighted: boolean
onSelect: (status: string) => void
onMouseEnter: () => void
colorEditing: boolean
onToggleColor: (status: string) => void
onColorChange: (status: string, colorKey: string) => void
}) {
const style = getStatusStyle(status)
return (
<>
<div
className="flex w-full items-center gap-1 px-2 py-1 transition-colors"
style={{
borderRadius: 4,
backgroundColor: highlighted ? 'var(--muted)' : 'transparent',
}}
onMouseEnter={onMouseEnter}
>
<button
className="flex min-w-0 flex-1 items-center border-none bg-transparent p-0 text-left"
onClick={() => onSelect(status)}
data-testid={`status-option-${status}`}
>
<StatusPill status={status} />
</button>
<button
className="flex size-4 shrink-0 items-center justify-center rounded-full border-none p-0"
style={{ backgroundColor: style.color }}
onClick={() => onToggleColor(status)}
title="Change color"
data-testid={`status-color-swatch-${status}`}
/>
</div>
{colorEditing && <ColorPickerRow status={status} onColorChange={onColorChange} />}
</>
)
}
const SECTION_LABEL_STYLE = {
fontFamily: "'Inter', sans-serif",
fontSize: 9,
fontWeight: 500,
letterSpacing: '0',
}
function SectionLabel({ children }: { children: string }) {
return (
<div className="px-2 py-1">
<span className="text-muted-foreground" style={SECTION_LABEL_STYLE}>{children}</span>
</div>
)
}
interface StatusOptionProps {
highlightOffset: number
highlightIndex: number
colorEditingStatus: string | null
onSelect: (status: string) => void
onHighlight: (index: number) => void
onToggleColor: (status: string) => void
onColorChange: (status: string, colorKey: string) => void
}
function StatusOptionList({ statuses, ...props }: StatusOptionProps & { statuses: string[] }) {
return (
<>
{statuses.map((status, i) => (
<StatusOption
key={status}
status={status}
highlighted={props.highlightIndex === props.highlightOffset + i}
onSelect={props.onSelect}
onMouseEnter={() => props.onHighlight(props.highlightOffset + i)}
colorEditing={props.colorEditingStatus === status}
onToggleColor={props.onToggleColor}
onColorChange={props.onColorChange}
/>
))}
</>
)
}
function useStatusFiltering(query: string, vaultStatuses: string[]) {
return useMemo(() => {
const lowerQuery = query.toLowerCase()
const vaultSet = new Set(vaultStatuses.map(s => s.toLowerCase()))
const suggested = SUGGESTED_STATUSES.filter(
s => s.toLowerCase().includes(lowerQuery) && !vaultSet.has(s.toLowerCase()),
)
const vault = vaultStatuses.filter(s => s.toLowerCase().includes(lowerQuery))
return { suggestedFiltered: suggested, vaultFiltered: vault, allFiltered: [...vault, ...suggested] }
}, [query, vaultStatuses])
}
interface KeyboardNavOptions {
allFiltered: string[]
totalOptions: number
showCreateOption: boolean
query: string
onSave: (v: string) => void
onCancel: () => void
listRef: React.RefObject<HTMLDivElement | null>
}
function useStatusKeyboard(opts: KeyboardNavOptions) {
const { allFiltered, totalOptions, showCreateOption, query, onSave, onCancel, listRef } = opts
const [highlightIndex, setHighlightIndex] = useState(-1)
const scrollIntoView = useCallback((index: number) => {
const list = listRef.current
if (!list) return
const items = list.querySelectorAll('[data-testid^="status-option-"], [data-testid="status-create-option"]')
items[index]?.scrollIntoView({ block: 'nearest' })
}, [listRef])
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
switch (e.key) {
case 'ArrowDown': {
e.preventDefault()
const next = highlightIndex < totalOptions - 1 ? highlightIndex + 1 : 0
setHighlightIndex(next)
scrollIntoView(next)
break
}
case 'ArrowUp': {
e.preventDefault()
const prev = highlightIndex > 0 ? highlightIndex - 1 : totalOptions - 1
setHighlightIndex(prev)
scrollIntoView(prev)
break
}
case 'Enter': {
e.preventDefault()
const trimmed = query.trim()
if (highlightIndex >= 0 && highlightIndex < allFiltered.length) onSave(allFiltered[highlightIndex])
else if (showCreateOption && highlightIndex === allFiltered.length) onSave(trimmed)
else if (trimmed) onSave(trimmed)
break
}
case 'Escape':
e.preventDefault()
onCancel()
break
}
},
[highlightIndex, totalOptions, allFiltered, showCreateOption, query, onSave, onCancel, scrollIntoView],
)
const resetHighlight = useCallback(() => setHighlightIndex(-1), [])
return { highlightIndex, setHighlightIndex, handleKeyDown, resetHighlight }
}
export function StatusDropdown({
vaultStatuses,
onSave,
onCancel,
}: {
value: string
vaultStatuses: string[]
onSave: (newValue: string) => void
onCancel: () => void
}) {
const [query, setQuery] = useState('')
const [colorEditingStatus, setColorEditingStatus] = useState<string | null>(null)
const inputRef = useRef<HTMLInputElement>(null)
const listRef = useRef<HTMLDivElement>(null)
const anchorRef = useRef<HTMLDivElement>(null)
const dropdownRef = useRef<HTMLDivElement>(null)
useLayoutEffect(() => {
const node = dropdownRef.current
if (!node) return
const anchor = anchorRef.current?.parentElement
if (!anchor) return
const rect = anchor.getBoundingClientRect()
const dropW = 208
let left = rect.right - dropW
if (left < 8) left = 8
if (left + dropW > window.innerWidth - 8) left = window.innerWidth - dropW - 8
node.style.top = `${rect.bottom + 4}px`
node.style.left = `${left}px`
}, [])
useEffect(() => { inputRef.current?.focus() }, [])
const { suggestedFiltered, vaultFiltered, allFiltered } = useStatusFiltering(query, vaultStatuses)
const showCreateOption = useMemo(() => {
if (!query.trim()) return false
return !allFiltered.some(s => s.toLowerCase() === query.trim().toLowerCase())
}, [query, allFiltered])
const totalOptions = allFiltered.length + (showCreateOption ? 1 : 0)
const { highlightIndex, setHighlightIndex, handleKeyDown, resetHighlight } =
useStatusKeyboard({ allFiltered, totalOptions, showCreateOption, query, onSave, onCancel, listRef })
const handleToggleColor = useCallback((status: string) => {
setColorEditingStatus(prev => prev === status ? null : status)
}, [])
const handleColorChange = useCallback((status: string, colorKey: string) => {
const currentKey = getStatusColorKey(status)
setStatusColor(status, currentKey === colorKey ? null : colorKey)
setColorEditingStatus(null)
}, [])
const handleQueryChange = useCallback((value: string) => {
setQuery(value)
resetHighlight()
}, [resetHighlight])
const optionProps: StatusOptionProps = {
highlightOffset: 0,
highlightIndex,
colorEditingStatus,
onSelect: onSave,
onHighlight: setHighlightIndex,
onToggleColor: handleToggleColor,
onColorChange: handleColorChange,
}
return (
<span ref={anchorRef} data-testid="status-dropdown">
{createPortal(
<>
<div className="fixed inset-0 z-[12000]" onClick={onCancel} data-testid="status-dropdown-backdrop" />
<div
ref={dropdownRef}
className="fixed z-[12001] w-52 overflow-hidden rounded-lg border border-border bg-background shadow-lg"
data-testid="status-dropdown-popover"
>
<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 => handleQueryChange(e.target.value)}
onKeyDown={handleKeyDown}
data-testid="status-search-input"
/>
</div>
<div ref={listRef} className="max-h-52 overflow-y-auto py-1">
<VaultSection statuses={vaultFiltered} {...optionProps} />
<SuggestedSection
statuses={suggestedFiltered}
showDivider={vaultFiltered.length > 0}
{...optionProps}
highlightOffset={vaultFiltered.length}
/>
<CreateSection
show={showCreateOption}
query={query}
showDivider={allFiltered.length > 0}
highlighted={highlightIndex === allFiltered.length}
onSave={onSave}
onMouseEnter={() => setHighlightIndex(allFiltered.length)}
/>
{allFiltered.length === 0 && !showCreateOption && (
<div className="px-2 py-2 text-center text-[11px] text-muted-foreground">
No matching statuses
</div>
)}
</div>
</div>
</>,
document.body
)}
</span>
)
}
function VaultSection({ statuses, ...props }: StatusOptionProps & { statuses: string[] }) {
if (statuses.length === 0) return null
return (
<div>
<SectionLabel>From vault</SectionLabel>
<StatusOptionList statuses={statuses} {...props} />
</div>
)
}
function SuggestedSection({ statuses, showDivider, ...props }: StatusOptionProps & { statuses: string[]; showDivider: boolean }) {
if (statuses.length === 0) return null
return (
<div>
{showDivider && <div className="my-1 h-px bg-border" />}
<SectionLabel>Suggested</SectionLabel>
<StatusOptionList statuses={statuses} {...props} />
</div>
)
}
function CreateSection({ show, query, showDivider, highlighted, onSave, onMouseEnter }: {
show: boolean; query: string; showDivider: boolean; highlighted: boolean
onSave: (v: string) => void; onMouseEnter: () => void
}) {
if (!show) return null
const trimmed = query.trim()
return (
<>
{showDivider && <div className="my-1 h-px bg-border" />}
<button
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: highlighted ? 'var(--muted)' : 'transparent',
color: 'var(--muted-foreground)',
}}
onClick={() => onSave(trimmed)}
onMouseEnter={onMouseEnter}
data-testid="status-create-option"
>
Create <StatusPill status={trimmed} />
</button>
</>
)
}