Edit Popover
An inline editing popover for quick field updates without leaving the page.
Installation
$
Usage
import { EditPopover } from "@/components/ui/edit-popover"Examples
Default
Positioning
Control where the popover opens with side (top/right/bottom/left) and align (start/center/end); both pass through to the underlying PopoverContent. Pick a cell and the code below updates to match your selection.
Popover placement
side="bottom" align="start"
API Reference
| Prop | Type | Default | Description |
|---|---|---|---|
| fields* | Field[] | - | The editable fields. Each is a text field (key, label, placeholder) or a color field (key, label, colors). |
| values* | Record<string, string> | - | Current field values, keyed by each field's key. |
| onSave* | (values) => Promise<void> | - | Called with the edited values on save. The popover closes when it resolves. |
| trigger* | React.ReactNode | - | The element that opens the popover. Rendered via the render prop, which merges Base UI's props onto it. |
| side | "top" | "right" | "bottom" | "left" | "bottom" | Preferred edge of the trigger to open against. |
| align | "start" | "center" | "end" | "end" | Alignment against the trigger along the chosen side. |
| avoidCollisions | boolean | true | When true, flips/shifts to stay in view. Set false to always honor side. |
| className | string | - | Extra classes for the popover content. |
"use client";
import { useState, useRef, useCallback, useId } from "react";
import { Popover, PopoverTrigger, PopoverContent } from "@/registry/ui/popover";
import { Input } from "@/registry/ui/input";
import { Button } from "@/registry/ui/button";
import { cn } from "@/registry/lib/utils";
interface TextField {
key: string;
type: "text";
label: string;
placeholder?: string;
}
interface ColorField {
key: string;
type: "color";
label: string;
colors: string[];
}
type Field = TextField | ColorField;
interface EditPopoverProps {
fields: Field[];
values: Record<string, string>;
onSave: (values: Record<string, string>) => Promise<void>;
trigger: React.ReactNode;
side?: "top" | "right" | "bottom" | "left";
align?: "start" | "center" | "end";
avoidCollisions?: boolean;
className?: string;
}
export function EditPopover({ fields, values, onSave, trigger, side, align = "end", avoidCollisions, className }: EditPopoverProps) {
const [open, setOpen] = useState(false);
const [localValues, setLocalValues] = useState<Record<string, string>>(values);
const [saving, setSaving] = useState(false);
const firstInputRef = useRef<HTMLInputElement>(null);
const baseId = useId();
const handleOpenChange = useCallback((nextOpen: boolean) => {
if (nextOpen) {
setLocalValues(values);
}
setOpen(nextOpen);
}, [values]);
const handleSave = useCallback(async () => {
if (saving) return;
setSaving(true);
try {
await onSave(localValues);
setOpen(false);
} catch (error) {
console.error("EditPopover save failed:", error);
} finally {
setSaving(false);
}
}, [saving, onSave, localValues]);
const setValue = useCallback((key: string, value: string) => {
setLocalValues((prev) => ({ ...prev, [key]: value }));
}, []);
return (
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger render={trigger as React.ReactElement} />
<PopoverContent
side={side}
align={align}
collisionAvoidance={
avoidCollisions === false ? { side: "none", align: "none" } : undefined
}
className={cn("w-56 p-3 space-y-3", className)}
initialFocus={firstInputRef}
>
{fields.map((field, i) => {
if (field.type === "text") {
const fieldId = `${baseId}-${field.key}`;
return (
<div key={field.key} className="space-y-1">
<label htmlFor={fieldId} className="text-xs font-medium text-muted-foreground">{field.label}</label>
<Input
id={fieldId}
ref={i === 0 ? firstInputRef : undefined}
value={localValues[field.key] ?? ""}
onChange={(e) => setValue(field.key, e.target.value)}
placeholder={field.placeholder}
onKeyDown={(e) => {
if (e.key === "Enter") handleSave();
}}
/>
</div>
);
}
if (field.type === "color") {
const groupLabelId = `${baseId}-${field.key}-label`;
return (
<div key={field.key} className="space-y-1">
<span id={groupLabelId} className="block text-xs font-medium text-muted-foreground">{field.label}</span>
<div role="radiogroup" aria-labelledby={groupLabelId} className="flex items-center gap-1.5 flex-wrap">
{field.colors.map((color) => (
<button
key={color}
type="button"
role="radio"
aria-label={`Color ${color}`}
aria-checked={localValues[field.key] === color}
onClick={() => setValue(field.key, color)}
className={cn(
"size-3.5 p-0 border-0 rounded-full transition-shadow duration-150 cursor-pointer ring-offset-1 ring-offset-popover outline-none focus-visible:ring-2 focus-visible:ring-ring-focus",
localValues[field.key] === color
? "ring-2 ring-foreground"
: "hover:ring-2 hover:ring-ring-focus"
)}
style={{ backgroundColor: color }}
/>
))}
</div>
</div>
);
}
return null;
})}
<div className="flex justify-end">
<Button size="sm" onClick={handleSave} disabled={saving}>
{saving ? "Saving..." : "Save"}
</Button>
</div>
</PopoverContent>
</Popover>
);
}