Skip to content

Commit eefbab7

Browse files
rafavallsclaude
andcommitted
overhaul variant UI: editable card, picker dialog, name editing
Reworks the variants experience end-to-end so the page-level and section-level variant flows share the same components, sizing, and behaviour. - Replace the custom matcher-picker modal with a CommandDialog list - Convert the variant rule area into a collapsible bordered card (state persisted in localStorage) with the Rule picker and schema fields aligned at the same px-5 inset - Make the variant title in the breadcrumb inline-editable via a pencil affordance; persist as a top-level "name" on the raw multivariate variant (exposed through CmsVariant / PageVariantEntry) - Rebuild DateField as a styled overlay over a real datetime-local input so users can both type and pick; restore the input border beneath the overlay - Unify both variant lists on a single SortableVariantItem (flag icon only, no colored chip) with drag-reorder for page variants too - Allow adding the first page variant from a flat-sections page - Add a delete button inside the page-variant detail breadcrumb - Drop all-caps title styling; bring section labels to text-sm font-semibold to match the FieldLabel scale Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent ff26b72 commit eefbab7

3 files changed

Lines changed: 842 additions & 384 deletions

File tree

api/tools/files.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -757,6 +757,7 @@ export type CmsVariant = {
757757
value: Record<string, unknown>;
758758
rule: Record<string, unknown>;
759759
label: string;
760+
name?: string;
760761
};
761762

762763
export type CmsSection = {
@@ -797,6 +798,7 @@ const cmsSectionItemSchema = z
797798
value: z.record(z.string(), z.unknown()),
798799
rule: z.record(z.string(), z.unknown()),
799800
label: z.string(),
801+
name: z.string().optional(),
800802
}),
801803
)
802804
.optional(),
@@ -816,6 +818,7 @@ export const getPageSectionsOutputSchema = z.object({
816818
label: z.string(),
817819
rule: z.record(z.string(), z.unknown()),
818820
sections: z.array(cmsSectionItemSchema),
821+
name: z.string().optional(),
819822
}),
820823
)
821824
.optional(),
@@ -1099,6 +1102,7 @@ export const getPageSectionsTool = createTool({
10991102
variants?: Array<{
11001103
value?: Record<string, unknown>;
11011104
rule?: Record<string, unknown>;
1105+
name?: string;
11021106
}>;
11031107
};
11041108
const rawVariants = Array.isArray(mvObj.variants)
@@ -1143,6 +1147,9 @@ export const getPageSectionsTool = createTool({
11431147
value,
11441148
rule,
11451149
label: formatMatcher(rule),
1150+
...(typeof v.name === "string" && v.name
1151+
? { name: v.name }
1152+
: {}),
11461153
};
11471154
});
11481155
const firstValueRt = (
@@ -1182,6 +1189,7 @@ export const getPageSectionsTool = createTool({
11821189
label: string;
11831190
rule: Record<string, unknown>;
11841191
sections: CmsSection[];
1192+
name?: string;
11851193
};
11861194

11871195
let pageVariants: PageVariantEntry[] | undefined;
@@ -1200,6 +1208,7 @@ export const getPageSectionsTool = createTool({
12001208
variants?: Array<{
12011209
value?: unknown[];
12021210
rule?: Record<string, unknown>;
1211+
name?: string;
12031212
}>;
12041213
};
12051214
const mvVariants = Array.isArray(mvField.variants)
@@ -1210,7 +1219,12 @@ export const getPageSectionsTool = createTool({
12101219
const rule = (v.rule ?? {}) as Record<string, unknown>;
12111220
const label = formatMatcher(rule);
12121221
const { sections } = parseSectionsFromArray(varSections);
1213-
return { label, rule, sections };
1222+
return {
1223+
label,
1224+
rule,
1225+
sections,
1226+
...(typeof v.name === "string" && v.name ? { name: v.name } : {}),
1227+
};
12141228
});
12151229
// Default display: first variant's sections
12161230
rawSections = Array.isArray(mvVariants[0]?.value)

web/tools/file-explorer/cms-form.tsx

Lines changed: 99 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import {
2929
Trash2,
3030
Upload,
3131
VideoIcon,
32+
X,
3233
} from "lucide-react";
3334
import {
3435
createContext,
@@ -306,6 +307,20 @@ function formatForNativeInput(
306307
return `${y}-${m}-${d}T${hh}:${mm}`;
307308
}
308309

310+
function formatDateDisplay(
311+
dateStr: string | undefined,
312+
mode: "date" | "date-time",
313+
): string {
314+
if (!dateStr) return "";
315+
const date = new Date(dateStr);
316+
if (Number.isNaN(date.getTime())) return "";
317+
return new Intl.DateTimeFormat("en-US",
318+
mode === "date"
319+
? { month: "short", day: "numeric", year: "numeric" }
320+
: { month: "short", day: "numeric", year: "numeric", hour: "numeric", minute: "2-digit" },
321+
).format(date);
322+
}
323+
309324
function DateField({
310325
label,
311326
description,
@@ -322,10 +337,9 @@ function DateField({
322337
const inputType = mode === "date" ? "date" : "datetime-local";
323338
const inputRef = useRef<HTMLInputElement>(null);
324339
const [local, setLocal] = useState(() => formatForNativeInput(value, mode));
340+
const [focused, setFocused] = useState(false);
325341

326-
// Sync external value changes, but only if the formatted value actually
327-
// differs from what we already have — avoids re-renders that kill the
328-
// native date picker popup.
342+
// Sync external value changes without clobbering an open picker.
329343
const prevFormattedRef = useRef(local);
330344
useEffect(() => {
331345
const formatted = formatForNativeInput(value, mode);
@@ -353,47 +367,104 @@ function DateField({
353367
}
354368
};
355369

370+
const handleBlur = () => {
371+
setFocused(false);
372+
// If user typed an unparseable value, snap back to the source-of-truth value.
373+
const formatted = formatForNativeInput(value, mode);
374+
if (formatted !== local) {
375+
setLocal(formatted);
376+
prevFormattedRef.current = formatted;
377+
}
378+
};
379+
380+
const handleClear = (e: React.MouseEvent) => {
381+
e.preventDefault();
382+
e.stopPropagation();
383+
setLocal("");
384+
prevFormattedRef.current = "";
385+
onChange("");
386+
};
387+
388+
const openPicker = (e: React.MouseEvent) => {
389+
e.preventDefault();
390+
e.stopPropagation();
391+
const el = inputRef.current;
392+
if (!el) return;
393+
if (typeof el.showPicker === "function") {
394+
try {
395+
el.showPicker();
396+
return;
397+
} catch {
398+
// fall through to focus
399+
}
400+
}
401+
el.focus();
402+
};
403+
404+
const display = formatDateDisplay(value, mode);
405+
const placeholder = mode === "date" ? "Set a date…" : "Set date & time…";
406+
const showOverlay = !focused;
407+
356408
return (
357409
<div className="space-y-1">
358410
<FieldLabel label={label} description={description} />
359-
<div className="relative flex items-center">
411+
<div className="relative">
360412
<input
361413
ref={inputRef}
362414
type={inputType}
363415
value={local}
364416
onChange={handleChange}
365-
className="flex h-7 w-full rounded-md border border-input bg-transparent px-2 pr-7 text-xs shadow-xs outline-none transition-colors focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 [&::-webkit-calendar-picker-indicator]{opacity:0;width:0}"
417+
onFocus={() => setFocused(true)}
418+
onBlur={handleBlur}
419+
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"
366420
/>
421+
{showOverlay && (
422+
<div className="pointer-events-none absolute inset-0 flex items-center gap-2 rounded-md border border-input bg-background px-3 pr-9 text-sm shadow-xs">
423+
<svg
424+
width="14"
425+
height="14"
426+
viewBox="0 0 16 16"
427+
fill="none"
428+
aria-hidden="true"
429+
className="shrink-0 text-muted-foreground"
430+
>
431+
<rect x="1" y="3" width="14" height="12" rx="2" stroke="currentColor" strokeWidth="1.5" />
432+
<path d="M1 7h14" stroke="currentColor" strokeWidth="1.5" />
433+
<path d="M5 1v4M11 1v4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
434+
</svg>
435+
<span className={display ? "flex-1 truncate" : "flex-1 truncate text-muted-foreground/40"}>
436+
{display || placeholder}
437+
</span>
438+
</div>
439+
)}
440+
{value && !focused && (
441+
<button
442+
type="button"
443+
onMouseDown={(e) => e.preventDefault()}
444+
onClick={handleClear}
445+
className="absolute right-8 top-1/2 z-10 -translate-y-1/2 rounded p-0.5 text-muted-foreground/60 hover:text-foreground"
446+
title="Clear"
447+
>
448+
<X className="h-3.5 w-3.5" />
449+
</button>
450+
)}
367451
<button
368452
type="button"
369-
onClick={() => inputRef.current?.showPicker()}
370-
className="absolute right-1.5 flex h-5 w-5 items-center justify-center rounded text-muted-foreground hover:text-foreground"
453+
onMouseDown={(e) => e.preventDefault()}
454+
onClick={openPicker}
455+
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"
371456
title="Open picker"
372457
>
373458
<svg
374-
width="12"
375-
height="12"
459+
width="14"
460+
height="14"
376461
viewBox="0 0 16 16"
377462
fill="none"
378463
aria-hidden="true"
379464
>
380-
<title>Calendar</title>
381-
<rect
382-
x="1"
383-
y="3"
384-
width="14"
385-
height="12"
386-
rx="2"
387-
stroke="currentColor"
388-
strokeWidth="1.5"
389-
/>
465+
<rect x="1" y="3" width="14" height="12" rx="2" stroke="currentColor" strokeWidth="1.5" />
390466
<path d="M1 7h14" stroke="currentColor" strokeWidth="1.5" />
391-
<path
392-
d="M5 1v4M11 1v4"
393-
stroke="currentColor"
394-
strokeWidth="1.5"
395-
strokeLinecap="round"
396-
/>
467+
<path d="M5 1v4M11 1v4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
397468
</svg>
398469
</button>
399470
</div>
@@ -1221,7 +1292,7 @@ function ImageField({
12211292
<div className="px-3 py-2">
12221293
<p className="truncate font-mono text-xs font-semibold">{stem}</p>
12231294
{ext && (
1224-
<p className="text-[10px] uppercase text-muted-foreground">
1295+
<p className="text-[10px] text-muted-foreground">
12251296
{ext}
12261297
</p>
12271298
)}
@@ -1356,7 +1427,7 @@ function MediaField({
13561427
{stem}
13571428
</p>
13581429
{ext && (
1359-
<p className="text-[10px] uppercase text-muted-foreground">
1430+
<p className="text-[10px] text-muted-foreground">
13601431
{ext}
13611432
</p>
13621433
)}
@@ -1370,7 +1441,7 @@ function MediaField({
13701441
{stem}
13711442
</p>
13721443
{ext && (
1373-
<p className="text-[10px] uppercase text-muted-foreground">
1444+
<p className="text-[10px] text-muted-foreground">
13741445
{ext}
13751446
</p>
13761447
)}

0 commit comments

Comments
 (0)