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>
382 lines
13 KiB
TypeScript
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>
|
|
</>
|
|
)
|
|
}
|