+
+
+
+
+
+
+ Markdown
+
+
+ {tab === "write" ? (
+
+ );
+}
+
+// ─── 7. ReferenceField — pick another entry ──────────────────────────────────
+
+export interface ReferenceOption {
+ id: string;
+ title: string;
+ subtitle?: string;
+ thumbnail?: string;
+}
+
+const DEMO_REFERENCES: ReferenceOption[] = [
+ {
+ id: "post-1",
+ title: "How we redesigned the form editor",
+ subtitle: "Article · 2026-05-01",
+ thumbnail:
+ "https://images.unsplash.com/photo-1517245386807-bb43f82c33c4?w=80&auto=format&fit=crop&q=80",
+ },
+ {
+ id: "post-2",
+ title: "The case for fewer fields",
+ subtitle: "Article · 2026-04-12",
+ },
+ {
+ id: "page-home",
+ title: "Home",
+ subtitle: "Page · /",
+ },
+ {
+ id: "page-pricing",
+ title: "Pricing",
+ subtitle: "Page · /pricing",
+ },
+ {
+ id: "product-shoes",
+ title: "Trail Runner GTX",
+ subtitle: "Product · $189",
+ thumbnail:
+ "https://images.unsplash.com/photo-1542291026-7eec264c27ff?w=80&auto=format&fit=crop&q=80",
+ },
+ {
+ id: "product-jacket",
+ title: "Mountain Shell Jacket",
+ subtitle: "Product · $329",
+ },
+ {
+ id: "category-mens",
+ title: "Men's apparel",
+ subtitle: "Category · 142 products",
+ },
+];
+
+export function ReferenceField({
+ label,
+ description,
+ value,
+ onChange,
+ options = DEMO_REFERENCES,
+}: {
+ label: string;
+ description?: string;
+ value: string;
+ onChange: (v: string) => void;
+ options?: ReferenceOption[];
+}) {
+ const [open, setOpen] = useState(false);
+ const [query, setQuery] = useState("");
+
+ const selected = options.find((o) => o.id === value);
+ const filtered = query
+ ? options.filter(
+ (o) =>
+ o.title.toLowerCase().includes(query.toLowerCase()) ||
+ o.subtitle?.toLowerCase().includes(query.toLowerCase()),
+ )
+ : options;
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ setQuery(e.target.value)}
+ placeholder="Search references…"
+ className="h-9 pl-8 text-sm"
+ />
+
+
+
+ {filtered.length === 0 ? (
+
+ No results.
+
+ ) : (
+ filtered.map((o) => (
+
{
+ onChange(o.id);
+ setOpen(false);
+ setQuery("");
+ }}
+ className="flex w-full items-center gap-3 rounded-sm px-2 py-2 text-left transition-colors hover:bg-accent"
+ >
+ {o.thumbnail ? (
+
+ ) : (
+
+
+
+ )}
+
+
{o.title}
+ {o.subtitle && (
+
+ {o.subtitle}
+
+ )}
+
+ {value === o.id && (
+
+ )}
+
+ ))
+ )}
+
+
+
+
+ );
+}
+
+// ─── 8. RangeField — slider with numeric readout ─────────────────────────────
+
+export function RangeField({
+ label,
+ description,
+ value,
+ onChange,
+ min = 0,
+ max = 100,
+ step = 1,
+ unit = "",
+}: {
+ label: string;
+ description?: string;
+ value: number;
+ onChange: (v: number) => void;
+ min?: number;
+ max?: number;
+ step?: number;
+ unit?: string;
+}) {
+ return (
+
+
+
+
+ {value}
+ {unit}
+
+
+
+ onChange(vs[0] ?? min)}
+ />
+
+
+
+ {min}
+ {unit}
+
+
+ {max}
+ {unit}
+
+
+
+ );
+}
+
+// ─── 9. DateRangeField — from/to picker ──────────────────────────────────────
+
+export function DateRangeField({
+ label,
+ description,
+ value,
+ onChange,
+}: {
+ label: string;
+ description?: string;
+ value: { from?: string; to?: string };
+ onChange: (v: { from?: string; to?: string }) => void;
+}) {
+ const [open, setOpen] = useState(false);
+ const fromDate = value?.from ? new Date(value.from) : undefined;
+ const toDate = value?.to ? new Date(value.to) : undefined;
+
+ const display =
+ fromDate && toDate
+ ? `${formatDate(fromDate, "MMM d")} → ${formatDate(toDate, "MMM d, yyyy")}`
+ : fromDate
+ ? `${formatDate(fromDate, "MMM d, yyyy")} → …`
+ : "Select a range";
+
+ return (
+
+
+
+
+
+
+ {display}
+
+
+
+ {
+ onChange({
+ from: range?.from
+ ? formatDate(range.from, "yyyy-MM-dd")
+ : undefined,
+ to: range?.to ? formatDate(range.to, "yyyy-MM-dd") : undefined,
+ });
+ }}
+ numberOfMonths={2}
+ />
+
+
+
+ );
+}
+
+// ─── 10. TimeField — custom popover with hour + minute columns ───────────────
+
+const HOURS = Array.from({ length: 24 }, (_, i) => String(i).padStart(2, "0"));
+const MINUTES = Array.from({ length: 12 }, (_, i) =>
+ String(i * 5).padStart(2, "0"),
+);
+
+function parseTime(value: string): { h: string; m: string } | null {
+ const m = /^(\d{1,2}):(\d{1,2})$/.exec(value || "");
+ if (!m) return null;
+ return {
+ h: m[1].padStart(2, "0"),
+ m: m[2].padStart(2, "0"),
+ };
+}
+
+function normalizeTimeInput(raw: string): string | null {
+ const cleaned = raw.replace(/[^\d:]/g, "");
+ if (!cleaned) return null;
+ let h: string;
+ let m: string;
+ if (cleaned.includes(":")) {
+ const [hh, mm = ""] = cleaned.split(":");
+ h = hh.padStart(2, "0");
+ m = (mm || "00").padStart(2, "0");
+ } else if (cleaned.length <= 2) {
+ h = cleaned.padStart(2, "0");
+ m = "00";
+ } else {
+ h = cleaned.slice(0, 2);
+ m = cleaned.slice(2, 4).padStart(2, "0");
+ }
+ const hi = Number(h);
+ const mi = Number(m);
+ if (Number.isNaN(hi) || Number.isNaN(mi)) return null;
+ if (hi < 0 || hi > 23 || mi < 0 || mi > 59) return null;
+ return `${String(hi).padStart(2, "0")}:${String(mi).padStart(2, "0")}`;
+}
+
+export function TimeField({
+ label,
+ description,
+ value,
+ onChange,
+ compact = false,
+}: {
+ label: string;
+ description?: string;
+ value: string;
+ onChange: (v: string) => void;
+ /** Compact mode hides the clock prefix block — for use next to a date picker */
+ compact?: boolean;
+}) {
+ const [open, setOpen] = useState(false);
+ const [draft, setDraft] = useState(value);
+ useEffect(() => setDraft(value), [value]);
+
+ const parsed = parseTime(value);
+ const hourValue = parsed?.h ?? "";
+ const minuteValue = parsed?.m ?? "";
+
+ const setHour = (h: string) => {
+ onChange(`${h}:${minuteValue || "00"}`);
+ };
+ const setMinute = (m: string) => {
+ onChange(`${hourValue || "00"}:${m}`);
+ };
+
+ const commitDraft = () => {
+ const normalized = normalizeTimeInput(draft);
+ if (normalized) {
+ onChange(normalized);
+ setDraft(normalized);
+ } else {
+ setDraft(value);
+ }
+ };
+
+ const inner = (
+
+ {!compact && (
+
+
+
+ )}
+
setDraft(e.target.value)}
+ onBlur={commitDraft}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") {
+ commitDraft();
+ (e.target as HTMLInputElement).blur();
+ }
+ }}
+ placeholder="HH:MM"
+ inputMode="numeric"
+ maxLength={5}
+ className={cn(
+ "min-w-0 flex-1 bg-transparent text-sm tabular-nums outline-none placeholder:text-muted-foreground",
+ compact ? "px-2.5 text-center" : "px-3",
+ )}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {value || "—"}
+
+ setOpen(false)}
+ >
+ Done
+
+
+
+
+
+ );
+
+ if (compact) return inner;
+
+ return (
+
+
+ {inner}
+
+ );
+}
+
+function TimeColumn({
+ label,
+ options,
+ selected,
+ onSelect,
+}: {
+ label: string;
+ options: string[];
+ selected: string;
+ onSelect: (v: string) => void;
+}) {
+ return (
+
+
+ {label}
+
+
+ {options.map((opt) => {
+ const active = opt === selected;
+ return (
+ onSelect(opt)}
+ className={cn(
+ "flex w-full items-center justify-center rounded-sm py-1.5 text-sm transition-colors",
+ active
+ ? "bg-primary text-primary-foreground"
+ : "text-foreground hover:bg-accent",
+ )}
+ >
+ {opt}
+
+ );
+ })}
+
+
+ );
+}
+
+// ─── 11. IconField — pick from Lucide library ───────────────────────────────
+
+const ICON_MAP: Record
= {
+ Award,
+ Bell,
+ Bike,
+ Bookmark,
+ Bus,
+ Calendar: CalendarIcon,
+ Camera,
+ Car,
+ Check,
+ Clock,
+ Cloud,
+ Coffee,
+ Cookie,
+ CreditCard,
+ Download,
+ Eye,
+ EyeOff,
+ Flag,
+ Gift,
+ Globe,
+ Headphones,
+ Heart,
+ Home,
+ Image,
+ Link: LinkIcon,
+ Lock,
+ Mail,
+ MapPin,
+ MessageCircle,
+ Mic,
+ Minus,
+ Moon,
+ Music,
+ Package,
+ Phone,
+ Pizza,
+ Plane,
+ Plus,
+ Search,
+ Send,
+ Settings,
+ Share,
+ Shield,
+ ShoppingBag,
+ ShoppingCart,
+ Smile,
+ Star,
+ Sun,
+ Tag,
+ ThumbsUp,
+ Train,
+ Trophy,
+ Truck,
+ Upload,
+ User,
+ Users,
+ Utensils,
+ Video,
+ X,
+ Zap,
+};
+const POPULAR_ICONS = Object.keys(ICON_MAP);
+
+export function IconField({
+ label,
+ description,
+ value,
+ onChange,
+}: {
+ label: string;
+ description?: string;
+ value: string;
+ onChange: (v: string) => void;
+}) {
+ const [open, setOpen] = useState(false);
+ const [query, setQuery] = useState("");
+
+ const Selected = ICON_MAP[value];
+ const filtered = query
+ ? POPULAR_ICONS.filter((n) => n.toLowerCase().includes(query.toLowerCase()))
+ : POPULAR_ICONS;
+
+ return (
+
+
+
+
+
+
+ {Selected ? (
+
+ ) : (
+ ?
+ )}
+
+
+ {value || No icon}
+
+
+
+
+
+
+
+
+ setQuery(e.target.value)}
+ placeholder="Search icons…"
+ className="h-9 pl-8 text-sm"
+ />
+
+
+
+ {filtered.map((name) => {
+ const Icon = ICON_MAP[name];
+ if (!Icon) return null;
+ const active = value === name;
+ return (
+ {
+ onChange(name);
+ setOpen(false);
+ }}
+ className={cn(
+ "flex aspect-square items-center justify-center rounded-md border transition-colors",
+ active
+ ? "border-primary bg-primary/10 text-primary"
+ : "border-transparent text-muted-foreground hover:border-border hover:bg-accent hover:text-foreground",
+ )}
+ >
+
+
+ );
+ })}
+
+ {value && (
+
+ {
+ onChange("");
+ setOpen(false);
+ }}
+ >
+
+ Clear icon
+
+
+ )}
+
+
+
+ );
+}
diff --git a/web/tools/file-explorer/cms-form.tsx b/web/tools/file-explorer/cms-form.tsx
index 0308b2b..5c7163a 100644
--- a/web/tools/file-explorer/cms-form.tsx
+++ b/web/tools/file-explorer/cms-form.tsx
@@ -13,7 +13,10 @@ import {
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
+import { format as formatDate } from "date-fns";
import {
+ CalendarIcon,
+ Check,
ChevronDown,
ChevronLeft,
ChevronRight,
@@ -22,14 +25,17 @@ import {
GripVertical,
ImageIcon,
Link,
+ List,
+ ListOrdered,
Loader2,
MoreHorizontal,
+ Pipette,
Plus,
+ Quote,
Search,
Trash2,
Upload,
VideoIcon,
- X,
} from "lucide-react";
import {
createContext,
@@ -40,7 +46,9 @@ import {
useRef,
useState,
} from "react";
+import { HexColorPicker } from "react-colorful";
import { Button } from "@/components/ui/button.tsx";
+import { Calendar } from "@/components/ui/calendar.tsx";
import {
Dialog,
DialogContent,
@@ -55,6 +63,11 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu.tsx";
import { Input } from "@/components/ui/input.tsx";
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover.tsx";
import {
Select,
SelectContent,
@@ -71,6 +84,18 @@ import type {
UploadAssetOutput,
} from "../../../api/tools/assets.ts";
import type { SchemaProperty } from "../../../api/tools/files.ts";
+import {
+ DateRangeField,
+ IconField,
+ MarkdownField,
+ MultiSelectField,
+ RangeField,
+ ReferenceField,
+ SwitchField,
+ TagsField,
+ TimeField,
+ UrlField,
+} from "./cms-form-extras.tsx";
import { formatMatcherRule } from "./index.tsx";
import {
isVtexLoader,
@@ -93,12 +118,10 @@ export type FormValue =
// ─── helpers ──────────────────────────────────────────────────────────────────
-function nestClass(depth: number): string {
- // First nesting level: tiny padding, no rule — keeps the form spacious.
- // Deeper levels get a faint left border to show hierarchy. Indents are
- // kept small so 3+ levels of nesting don't squeeze the form area.
- if (depth === 0) return "pl-1";
- return "border-l border-border/50 pl-3";
+function nestClass(_depth: number): string {
+ // ml-3 lands the rule under the parent's chevron; pl-4 aligns child
+ // content under the parent label text.
+ return "ml-3 border-l border-border/60 pl-4";
}
function humanize(key: string): string {
@@ -154,13 +177,15 @@ function defaultForSchemaType(
export function FieldLabel({
label,
description,
+ className,
}: {
label: string;
description?: string;
+ className?: string;
}) {
if (!label) return null;
return (
-
+
{label}
@@ -199,7 +224,7 @@ export function TextField({
setLocal(e.target.value);
onChange(e.target.value);
}}
- className="h-9 text-sm"
+ className="h-10 text-sm"
/>
);
@@ -240,6 +265,45 @@ function TextareaField({
// ─── color input field ───────────────────────────────────────────────────────
+// react-colorful styling overrides — applied once per page
+const REACT_COLORFUL_CSS = `
+.cms-color-popover .react-colorful{width:100%;height:180px;gap:12px}
+.cms-color-popover .react-colorful__saturation{border-radius:6px;border-bottom:none}
+.cms-color-popover .react-colorful__hue{height:12px;border-radius:6px}
+.cms-color-popover .react-colorful__pointer{width:16px;height:16px;border-width:2px}
+`;
+let colorfulCssInjected = false;
+function ensureColorfulCss() {
+ if (colorfulCssInjected || typeof document === "undefined") return;
+ colorfulCssInjected = true;
+ const style = document.createElement("style");
+ style.textContent = REACT_COLORFUL_CSS;
+ document.head.appendChild(style);
+}
+
+const COLOR_PRESETS = [
+ "#000000",
+ "#ffffff",
+ "#ef4444",
+ "#f97316",
+ "#eab308",
+ "#22c55e",
+ "#14b8a6",
+ "#3b82f6",
+ "#6366f1",
+ "#a855f7",
+ "#ec4899",
+ "#64748b",
+];
+
+interface EyeDropperResult {
+ sRGBHex: string;
+}
+
+interface EyeDropperConstructor {
+ new (): { open: () => Promise
};
+}
+
function ColorInputField({
label,
description,
@@ -251,81 +315,144 @@ function ColorInputField({
value: string;
onChange: (v: string) => void;
}) {
- const [color, setColor] = useState(value || "#000000");
+ const initial = value || "#000000";
+ const [color, setColor] = useState(initial);
+ const [hexInput, setHexInput] = useState(initial.replace(/^#/, ""));
+ const [open, setOpen] = useState(false);
+ const hasEyedropper = typeof window !== "undefined" && "EyeDropper" in window;
useEffect(() => {
- setColor(value || "#000000");
+ ensureColorfulCss();
+ }, []);
+
+ useEffect(() => {
+ const next = value || "#000000";
+ setColor(next);
+ setHexInput(next.replace(/^#/, ""));
}, [value]);
+ const commit = (next: string) => {
+ setColor(next);
+ setHexInput(next.replace(/^#/, ""));
+ onChange(next);
+ };
+
+ const commitHex = (raw: string) => {
+ const cleaned = raw.startsWith("#") ? raw : `#${raw}`;
+ if (/^#[a-f\d]{6}$/i.test(cleaned)) commit(cleaned.toLowerCase());
+ };
+
+ const pickFromScreen = async () => {
+ if (!hasEyedropper) return;
+ try {
+ const Ctor = (window as unknown as { EyeDropper: EyeDropperConstructor })
+ .EyeDropper;
+ const result = await new Ctor().open();
+ commit(result.sRGBHex);
+ } catch {
+ // user dismissed
+ }
+ };
+
return (
-
+
-
+
+
+
+
+
+ {color}
+
+
+
+
+
+
+ {/* Picker */}
+
commit(c)} />
+
+ {/* Hex input + eyedropper */}
+
+
+
+ #
+
+
+ setHexInput(
+ e.target.value.replace(/[^a-f\d]/gi, "").slice(0, 6),
+ )
+ }
+ onBlur={() => commitHex(hexInput)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") {
+ commitHex(hexInput);
+ (e.target as HTMLInputElement).blur();
+ }
+ }}
+ placeholder="000000"
+ className="min-w-0 flex-1 bg-transparent px-2 font-mono text-sm uppercase outline-none placeholder:text-muted-foreground/40"
+ />
+
+ {hasEyedropper && (
+
+
+
+ )}
+
+
+ {/* Preset swatches */}
+
+
+ Presets
+
+
+ {COLOR_PRESETS.map((preset) => {
+ const active = color.toLowerCase() === preset.toLowerCase();
+ return (
+ commit(preset)}
+ title={preset}
+ className={cn(
+ "h-7 w-full rounded-md border transition-all",
+ active
+ ? "ring-2 ring-ring ring-offset-1 ring-offset-popover"
+ : "border-border/60 hover:scale-110",
+ )}
+ style={{ background: preset }}
+ />
+ );
+ })}
+
+
+
+
+
);
}
// ─── date / datetime field ───────────────────────────────────────────────────
-function formatForNativeInput(
- dateStr: string | undefined,
- mode: "date" | "date-time",
-): string {
- if (!dateStr) return "";
- const date = new Date(dateStr);
- if (Number.isNaN(date.getTime())) return dateStr;
- const y = date.getFullYear();
- const m = String(date.getMonth() + 1).padStart(2, "0");
- const d = String(date.getDate()).padStart(2, "0");
- if (mode === "date") return `${y}-${m}-${d}`;
- const hh = String(date.getHours()).padStart(2, "0");
- const mm = String(date.getMinutes()).padStart(2, "0");
- return `${y}-${m}-${d}T${hh}:${mm}`;
-}
-
-function formatDateDisplay(
- dateStr: string | undefined,
- mode: "date" | "date-time",
-): string {
- if (!dateStr) return "";
- const date = new Date(dateStr);
- if (Number.isNaN(date.getTime())) return "";
- return new Intl.DateTimeFormat(
- "en-US",
- mode === "date"
- ? { month: "short", day: "numeric", year: "numeric" }
- : {
- month: "short",
- day: "numeric",
- year: "numeric",
- hour: "numeric",
- minute: "2-digit",
- },
- ).format(date);
+function parseDate(value: string | undefined): Date | undefined {
+ if (!value) return undefined;
+ const d = new Date(value);
+ return Number.isNaN(d.getTime()) ? undefined : d;
}
function DateField({
@@ -341,155 +468,79 @@ function DateField({
onChange: (v: string) => void;
mode?: "date" | "date-time";
}) {
- const inputType = mode === "date" ? "date" : "datetime-local";
- const inputRef = useRef
(null);
- const [local, setLocal] = useState(() => formatForNativeInput(value, mode));
- const [focused, setFocused] = useState(false);
-
- // Sync external value changes without clobbering an open picker.
- const prevFormattedRef = useRef(local);
- useEffect(() => {
- const formatted = formatForNativeInput(value, mode);
- if (formatted !== prevFormattedRef.current) {
- prevFormattedRef.current = formatted;
- setLocal(formatted);
- }
- }, [value, mode]);
+ const [open, setOpen] = useState(false);
+ const date = parseDate(value);
+ const timeStr = date ? formatDate(date, "HH:mm") : "";
- const handleChange = (e: React.ChangeEvent) => {
- const raw = e.target.value;
- setLocal(raw);
- prevFormattedRef.current = raw;
- if (!raw) {
+ const updateDate = (next: Date | undefined) => {
+ if (!next) {
onChange("");
return;
}
if (mode === "date") {
- onChange(raw);
+ onChange(formatDate(next, "yyyy-MM-dd"));
} else {
- const parsed = new Date(raw);
- if (!Number.isNaN(parsed.getTime())) {
- onChange(parsed.toISOString());
+ // preserve hours/minutes from current value if any
+ if (date) {
+ next.setHours(date.getHours(), date.getMinutes(), 0, 0);
}
+ onChange(next.toISOString());
}
};
- const handleBlur = () => {
- setFocused(false);
- const formatted = formatForNativeInput(value, mode);
- if (formatted !== local) {
- setLocal(formatted);
- prevFormattedRef.current = formatted;
- }
- };
-
- const handleClear = () => {
- setLocal("");
- prevFormattedRef.current = "";
- onChange("");
- };
-
- const openPicker = () => {
- inputRef.current?.showPicker();
+ const updateTime = (raw: string) => {
+ if (!date || !raw) return;
+ const [h, m] = raw.split(":").map(Number);
+ const next = new Date(date);
+ next.setHours(h ?? 0, m ?? 0, 0, 0);
+ onChange(next.toISOString());
};
- const display = formatDateDisplay(value, mode);
- const placeholder = mode === "date" ? "Set a date…" : "Set date & time…";
- const showOverlay = !focused;
+ const display = date
+ ? mode === "date"
+ ? formatDate(date, "MMM d, yyyy")
+ : formatDate(date, "MMM d, yyyy 'at' HH:mm")
+ : "Select a date";
return (
-
+
-
-
setFocused(true)}
- onBlur={handleBlur}
- className="flex h-9 w-full rounded-md border border-input bg-background px-3 pr-9 text-sm shadow-xs outline-none transition-colors focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 [&::-webkit-calendar-picker-indicator]:hidden [&::-webkit-calendar-picker-indicator]:appearance-none"
- />
- {showOverlay && (
-
-
-
+
+
+
- {display || placeholder}
-
-
- )}
- {value && !focused && (
-
e.preventDefault()}
- onClick={handleClear}
- className="absolute right-8 top-1/2 z-10 -translate-y-1/2 rounded p-0.5 text-muted-foreground/60 hover:text-foreground"
- title="Clear"
- >
-
-
- )}
-
e.preventDefault()}
- onClick={openPicker}
- className="absolute right-1 top-1/2 z-10 -translate-y-1/2 rounded p-1 text-muted-foreground/70 hover:bg-accent hover:text-foreground"
- title="Open picker"
- >
-
+
+
+ {
+ updateDate(d);
+ if (mode === "date") setOpen(false);
+ }}
+ captionLayout="dropdown"
/>
-
-
+
+ {mode === "date-time" && (
+
+ updateTime(v)}
+ compact
/>
-
-
+
+ )}
);
@@ -516,9 +567,11 @@ function CodeField({