Properties Panel

An inline-editable properties panel inspired by Notion and Linear. Supports text, select, multiselect, date, person, status and more property types — all editable inline.

Forms & InputsInspired by Notion + Linear
Live PreviewInteractive

Issue

Fix command palette keyboard navigation

Status
In Progress
Priority
High
Assignee
SCSarah Chen
Due date
May 30, 2026
Labels
BugFrontend
Estimate
3
Project
Design System
URL
github.com/issues/142

Document

Q3 Product Roadmap

Status
Published
Author
MJMarcus Johnson
Last edited
Today
Tags
RoadmapStrategyQ3
Views
284
Related
Add a link
PropertiesPanel.tsx
// 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