An inline-editable properties panel inspired by Notion and Linear. Supports text, select, multiselect, date, person, status and more property types — all editable inline.
Issue
Document
// Dependencies: react ^18, lucide-react
import React, { useState, useRef, useEffect } from 'react';
import {
Type, Calendar, ChevronDown, User, Hash, Link2,
CheckSquare, Square, Circle, Tag, Check,
} from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
type SelectOption = { id: string; label: string; color?: string };
type Property = {
id: string;
label: string;
type: 'text' | 'date' | 'select' | 'multiselect' | 'person' | 'number' | 'url' | 'checkbox' | 'status';
value: any;
options?: SelectOption[];
placeholder?: string;
};
type Props = {
title?: string;
properties: Property[];
onUpdate?: (id: string, value: any) => void;
};
const TYPE_ICONS: Record<Property['type'], LucideIcon> = {
text: Type, date: Calendar, select: ChevronDown, multiselect: Tag,
person: User, number: Hash, url: Link2, checkbox: CheckSquare, status: Circle,
};
const STATUS_COLORS: Record<string, string> = {
'Todo': 'bg-zinc-600', 'In Progress': 'bg-yellow-500', 'Done': 'bg-emerald-500',
'Cancelled': 'bg-zinc-500', 'Published': 'bg-emerald-500', 'Draft': 'bg-zinc-500',
'Review': 'bg-blue-500', 'High': 'bg-orange-500', 'Medium': 'bg-yellow-500', 'Low': 'bg-zinc-500',
};
const BADGE_COLOR_MAP: Record<string, string> = {
blue: 'bg-blue-500/15 text-blue-300 border-blue-500/20',
green: 'bg-emerald-500/15 text-emerald-300 border-emerald-500/20',
yellow: 'bg-yellow-500/15 text-yellow-300 border-yellow-500/20',
orange: 'bg-orange-500/15 text-orange-300 border-orange-500/20',
red: 'bg-red-500/15 text-red-300 border-red-500/20',
purple: 'bg-purple-500/15 text-purple-300 border-purple-500/20',
default: 'bg-zinc-800 text-zinc-400 border-zinc-700/50',
};
function BadgePill({ label, color }: { label: string; color?: string }) {
const cls = BADGE_COLOR_MAP[color ?? 'default'] ?? BADGE_COLOR_MAP.default;
return <span className={"text-[11px] px-1.5 py-0.5 rounded border font-medium " + cls}>{label}</span>;
}
function PropertyRow({ property, onUpdate }: { property: Property; onUpdate?: (id: string, value: any) => void }) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState<any>(property.value);
const [dropdownOpen, setDropdownOpen] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const Icon = TYPE_ICONS[property.type];
useEffect(() => { if (editing && inputRef.current) inputRef.current.focus(); }, [editing]);
const commit = (val: any) => { setEditing(false); setDropdownOpen(false); onUpdate?.(property.id, val); };
const renderValue = () => {
const v = draft;
if (property.type === 'status') {
return v ? (
<span className="flex items-center gap-1.5">
<span className={"w-2 h-2 rounded-full " + (STATUS_COLORS[v] ?? 'bg-zinc-600')} />
<span className="text-[13px] text-zinc-300">{v}</span>
</span>
) : <span className="text-[13px] text-zinc-600">{property.placeholder ?? '—'}</span>;
}
if (property.type === 'select') {
if (!v) return <span className="text-[13px] text-zinc-600">{property.placeholder ?? '—'}</span>;
const opt = property.options?.find((o) => o.id === v);
return <BadgePill label={opt?.label ?? v} color={opt?.color} />;
}
if (property.type === 'multiselect') {
const vals = v as string[] | null;
if (!vals?.length) return <span className="text-[13px] text-zinc-600">{property.placeholder ?? '—'}</span>;
return <div className="flex flex-wrap gap-1">{vals.map((id) => { const opt = property.options?.find((o) => o.id === id); return <BadgePill key={id} label={opt?.label ?? id} color={opt?.color} />; })}</div>;
}
if (property.type === 'person') {
return v ? (
<span className="flex items-center gap-1.5">
<span className="w-5 h-5 rounded-full flex items-center justify-center text-[9px] font-bold text-white shrink-0" style={{ backgroundColor: v.color }}>{v.initials}</span>
<span className="text-[13px] text-zinc-300">{v.name}</span>
</span>
) : <span className="text-[13px] text-zinc-600">Unassigned</span>;
}
if (property.type === 'checkbox') {
return (
<button onClick={() => commit(!v)} className="flex items-center">
{v ? <CheckSquare className="w-4 h-4 text-indigo-400" /> : <Square className="w-4 h-4 text-zinc-600" />}
</button>
);
}
if (property.type === 'url') {
return v ? <span className="text-[13px] text-indigo-400 truncate max-w-[160px]">{v.replace(/^https?:\/\//, '')}</span>
: <span className="text-[13px] text-zinc-600">{property.placeholder ?? '—'}</span>;
}
if (property.type === 'number') {
return v != null ? <span className="text-[13px] text-zinc-300 font-mono">{v}</span>
: <span className="text-[13px] text-zinc-600">{property.placeholder ?? '—'}</span>;
}
return v ? <span className="text-[13px] text-zinc-300">{v}</span>
: <span className="text-[13px] text-zinc-600">{property.placeholder ?? '—'}</span>;
};
const isSimpleEdit = ['text', 'number', 'url', 'date'].includes(property.type);
const isDropdown = ['select', 'multiselect', 'status'].includes(property.type);
return (
<div className="group relative">
<div
className="grid items-start py-1.5 px-3 hover:bg-white/[0.02] rounded-lg cursor-pointer transition-colors"
style={{ gridTemplateColumns: '40% 60%' }}
onClick={() => { if (property.type !== 'checkbox') { if (isSimpleEdit) setEditing(true); else if (isDropdown) setDropdownOpen((o) => !o); } }}
>
<div className="flex items-center gap-2 pt-0.5 min-w-0">
<Icon className="w-3 h-3 text-zinc-600 shrink-0" />
<span className="text-[12px] text-zinc-600 truncate">{property.label}</span>
</div>
<div className="min-w-0 pl-2">
{editing ? (
<input
ref={inputRef}
value={draft ?? ''}
onChange={(e) => setDraft(e.target.value)}
onBlur={() => commit(draft)}
onKeyDown={(e) => { if (e.key === 'Enter') commit(draft); if (e.key === 'Escape') { setEditing(false); setDraft(property.value); } }}
className="w-full bg-zinc-800 border border-indigo-500/40 rounded px-1.5 py-0.5 text-[13px] text-white outline-none"
onClick={(e) => e.stopPropagation()}
/>
) : renderValue()}
</div>
</div>
{dropdownOpen && property.options && (
<div className="absolute left-0 right-0 z-10 mt-0.5 mx-3 rounded-lg border border-white/[0.1] bg-[#1a1a1a] shadow-xl overflow-hidden">
{property.options.map((opt) => {
const isSelected = property.type === 'multiselect'
? ((draft as string[] | null) ?? []).includes(opt.id) : draft === opt.id;
return (
<button key={opt.id} className="w-full flex items-center gap-2 px-3 py-2 text-left hover:bg-white/[0.06] transition-colors"
onClick={(e) => { e.stopPropagation(); if (property.type === 'multiselect') { const cur = (draft as string[] | null) ?? []; const next = isSelected ? cur.filter((id) => id !== opt.id) : [...cur, opt.id]; setDraft(next); onUpdate?.(property.id, next); } else { commit(opt.id); } }}>
{isSelected ? <Check className="w-3 h-3 text-indigo-400 shrink-0" /> : <span className="w-3 h-3 shrink-0" />}
<BadgePill label={opt.label} color={opt.color} />
</button>
);
})}
<button className="w-full px-3 py-2 text-left text-[11px] text-zinc-600 hover:bg-white/[0.04] border-t border-white/[0.06]" onClick={(e) => { e.stopPropagation(); setDropdownOpen(false); }}>Close</button>
</div>
)}
</div>
);
}
export function PropertiesPanel({ title, properties, onUpdate }: Props) {
return (
<div className="rounded-xl border border-white/[0.08] bg-[#111] overflow-hidden">
{title && <div className="px-4 py-3 border-b border-white/[0.06]"><h3 className="text-sm font-semibold text-white">{title}</h3></div>}
<div className="py-2">
{properties.map((prop) => <PropertyRow key={prop.id} property={prop} onUpdate={onUpdate} />)}
</div>
</div>
);
}Unlock to copy
Free access to all patterns