- StatusDropdown: change anchor element from <div> to <span> to fix invalid HTML nesting inside <span> parents. Browsers auto-correct <div> inside <span> by ripping the element out, breaking anchorRef.parentElement positioning. JSDOM doesn't auto-correct, so tests passed but the real app's dropdown failed to position. - StatusValue: revert shrink-0 back to min-w-0 so the status pill can truncate in narrow panels instead of overflowing. - PropertyRow: add min-w-0 and gap-2 for proper flex overflow, wrap property key in truncate span. - AddPropertyForm: add flex-wrap, reduce input widths, set min-w-[60px] on value inputs for narrow panel support. - InfoRow/TypeSelector/ReadOnlyType: add min-w-0 and gap-2. - Tests: increase timeout for 3 Radix Select interaction tests that are slow in JSDOM. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
384 lines
13 KiB
TypeScript
384 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: "'IBM Plex Mono', monospace",
|
|
fontSize: 10,
|
|
fontWeight: 600,
|
|
letterSpacing: '1.2px',
|
|
textTransform: 'uppercase' as const,
|
|
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: "'IBM Plex Mono', monospace",
|
|
fontSize: 9,
|
|
fontWeight: 500,
|
|
letterSpacing: '1.2px',
|
|
textTransform: 'uppercase' as const,
|
|
}
|
|
|
|
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>
|
|
</>
|
|
)
|
|
}
|