From 31310b49f944859bd1e66fe535d04d4cafdcb326 Mon Sep 17 00:00:00 2001 From: Developing-Gamer Date: Wed, 15 Jul 2026 18:47:42 -0700 Subject: [PATCH] Refactor DesignEditableGrid with flat layout and explicit edit states Replace ghost hover fields with white control surfaces, add description support, shared popover styling, and clearer deferred-save validation. Co-authored-by: Cursor --- .../design-components/editable-grid.tsx | 917 +++++++++--------- .../src/components/design-components/index.ts | 3 +- 2 files changed, 452 insertions(+), 468 deletions(-) diff --git a/apps/dashboard/src/components/design-components/editable-grid.tsx b/apps/dashboard/src/components/design-components/editable-grid.tsx index d3374184a..6261283d8 100644 --- a/apps/dashboard/src/components/design-components/editable-grid.tsx +++ b/apps/dashboard/src/components/design-components/editable-grid.tsx @@ -1,6 +1,9 @@ "use client"; import { + Popover, + PopoverContent, + PopoverTrigger, Select, SelectContent, SelectItem, @@ -10,29 +13,23 @@ import { Spinner, } from "@/components/ui"; import { cn } from "@/lib/utils"; -import { useAsyncCallback } from "@hexclave/shared/dist/hooks/use-async-callback"; -import { throwErr } from "@hexclave/shared/dist/utils/errors"; import { runAsynchronouslyWithAlert } from "@hexclave/shared/dist/utils/promises"; -import { ArrowCounterClockwise, Check, FloppyDisk, X } from "@phosphor-icons/react"; -import { useRef, useState } from "react"; -import { DesignButton, useDesignEditMode, DesignInput } from "@hexclave/dashboard-ui-components"; +import { DesignButton, useDesignEditMode } from "@hexclave/dashboard-ui-components"; +import { + CaretDown, + Check, + PencilSimple, + X, +} from "@phosphor-icons/react"; +import { useRef, useState, type HTMLAttributes, type ReactNode } from "react"; export type DesignEditableGridSize = "sm" | "md"; -const sizeConfig = { - sm: { height: "h-7", minHeight: "min-h-7", padding: "px-2", customPl: "pl-2", gapX: "gap-x-1.5", interColPl: "lg:[&>*:nth-child(4n+3)]:pl-3" }, - md: { height: "h-8", minHeight: "min-h-8", padding: "px-3", customPl: "pl-3", gapX: "gap-x-3", interColPl: "lg:[&>*:nth-child(4n+3)]:pl-5" }, -} as const; - -// Ghost mode: hide visual decorations (bg, border, shadow, ring) by default -const ghostFieldClasses = "bg-transparent dark:bg-transparent border-transparent dark:border-transparent shadow-none ring-0"; -// Ghost mode: reveal visual decorations on hover -const ghostFieldHoverClasses = "hover:bg-white/80 dark:hover:bg-foreground/[0.03] hover:border-black/[0.08] dark:hover:border-white/[0.06] hover:shadow-sm hover:ring-1 hover:ring-black/[0.08] dark:hover:ring-white/[0.06]"; - type BaseItemProps = { itemKey?: string, - icon: React.ReactNode, + icon: ReactNode, name: string, + description?: string, tooltip?: string, }; @@ -43,6 +40,7 @@ type TextItem = BaseItemProps & { normalizeInput?: (value: string) => string, readOnly?: boolean, placeholder?: string, + inputMode?: HTMLAttributes["inputMode"], }; type BooleanItem = BaseItemProps & { @@ -54,7 +52,7 @@ type BooleanItem = BaseItemProps & { falseLabel?: string, }; -type DropdownOption = { +export type DesignEditableGridDropdownOption = { value: string, label: string, disabled?: boolean, @@ -64,19 +62,20 @@ type DropdownOption = { type DropdownItem = BaseItemProps & { type: "dropdown", value: string, - options: DropdownOption[], + options: DesignEditableGridDropdownOption[], onUpdate?: (value: string) => Promise, readOnly?: boolean, + placeholder?: string, extraAction?: { label: string, - onClick: () => void, + onClick: () => void | Promise, }, }; type CustomDropdownItem = BaseItemProps & { type: "custom-dropdown", - triggerContent: React.ReactNode, - popoverContent: React.ReactNode, + triggerContent: ReactNode, + popoverContent: ReactNode, open?: boolean, onOpenChange?: (open: boolean) => void, disabled?: boolean, @@ -84,14 +83,14 @@ type CustomDropdownItem = BaseItemProps & { type CustomButtonItem = BaseItemProps & { type: "custom-button", - children: React.ReactNode, - onClick: () => void, + children: ReactNode, + onClick: () => void | Promise, disabled?: boolean, }; type CustomContentItem = BaseItemProps & { type: "custom", - children: React.ReactNode, + children: ReactNode, }; export type DesignEditableGridItem = @@ -102,7 +101,7 @@ export type DesignEditableGridItem = | CustomButtonItem | CustomContentItem; -type DesignEditableGridProps = { +export type DesignEditableGridProps = { items: DesignEditableGridItem[], columns?: 1 | 2, size?: DesignEditableGridSize, @@ -112,564 +111,538 @@ type DesignEditableGridProps = { hasChanges?: boolean, onSave?: () => Promise, onDiscard?: () => void, - externalModifiedKeys?: Set, + externalModifiedKeys?: ReadonlySet, + "aria-label"?: string, }; -type DesignEditableInputProps = { - value: string, - onUpdate?: (value: string) => Promise, - normalizeInput?: (value: string) => string, - readOnly?: boolean, - placeholder?: string, - inputClassName?: string, - shiftTextToLeft?: boolean, - mode?: "text" | "password", +type FieldSizeConfig = { + control: "sm" | "md", + controlHeight: string, + controlPadding: string, + controlText: string, + labelHeight: string, + iconSize: string, + gapX: string, + gapY: string, }; -function DesignEditableInput({ - value, - onUpdate, - normalizeInput, - readOnly, - placeholder, - inputClassName, - shiftTextToLeft, - mode = "text", - editMode, - sz, -}: DesignEditableInputProps & { editMode: boolean, sz: typeof sizeConfig[DesignEditableGridSize] }) { - const [editValue, setEditValue] = useState(null); - const [hasChanged, setHasChanged] = useState(false); - const editing = editValue !== null; - const forceAllowBlur = useRef(false); - const containerRef = useRef(null); - const inputRef = useRef(null); - const acceptRef = useRef(null); - const [handleUpdate, isLoading] = useAsyncCallback(async (nextValue: string) => { - await onUpdate?.(nextValue); - }, [onUpdate]); +const sizeConfig = new Map([ + ["sm", { + control: "sm", + controlHeight: "h-8", + controlPadding: "px-2.5", + controlText: "text-sm", + labelHeight: "min-h-8", + iconSize: "h-6 w-6", + gapX: "gap-x-3", + gapY: "gap-y-3", + }], + ["md", { + control: "md", + controlHeight: "h-9", + controlPadding: "px-3", + controlText: "text-sm", + labelHeight: "min-h-9", + iconSize: "h-7 w-7", + gapX: "gap-x-4", + gapY: "gap-y-4", + }], +]); - return
{ - if (!readOnly) { - setEditValue(editValue ?? value); - } - }} - onBlur={() => { - if (forceAllowBlur.current) return; - if (!hasChanged) { - setEditValue(null); - } else if (confirm("You have unapplied changes. Would you like to save them?")) { - acceptRef.current?.click(); - } else { - setEditValue(null); - setHasChanged(false); - } - }} - onMouseDown={(ev) => { - if (containerRef.current?.contains(ev.target as Node)) { - ev.preventDefault(); - return false; - } - }} - > - { - setEditValue(normalizeInput?.(e.target.value) ?? e.target.value); - setHasChanged(true); - }} - onKeyDown={(e) => { - if (e.key === "Enter") { - acceptRef.current?.click(); - } - }} - onMouseDown={(ev) => { - ev.stopPropagation(); - }} - /> -
- {["accept", "reject"].map((action) => ( - runAsynchronouslyWithAlert(async () => { - try { - forceAllowBlur.current = true; - inputRef.current?.blur(); - if (action === "accept") { - await handleUpdate(editValue ?? throwErr("No value to update")); - } - setEditValue(null); - setHasChanged(false); - } finally { - forceAllowBlur.current = false; - } - })} - > - {action === "accept" - ? - : } - - ))} -
-
; +/** + * Shared editable control surface for DesignEditableGrid and page-local + * custom triggers that sit inside a grid value cell. White / neutral only — + * never tinted theme backgrounds that read as purple on glass pages. + */ +export const designEditableGridControlClassName = cn( + "rounded-lg border border-black/[0.1] bg-white text-foreground shadow-none", + "hover:bg-zinc-50 hover:border-black/[0.16]", + "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-black/[0.12]", + "dark:border-white/[0.1] dark:bg-zinc-950 dark:hover:bg-zinc-900 dark:hover:border-white/[0.18]", + "dark:focus-visible:ring-white/[0.16]", + "transition-colors duration-150 hover:transition-none", +); + +export const designEditableGridPopoverClassName = cn( + "w-64 rounded-xl border border-black/[0.1] bg-white p-3 shadow-lg", + "dark:border-white/[0.1] dark:bg-zinc-950", +); + +function getSizeConfig(size: DesignEditableGridSize) { + const config = sizeConfig.get(size); + if (config == null) { + throw new Error(`DesignEditableGrid does not define styles for size "${size}".`); + } + return config; } -function GridLabel({ - icon, - name, - tooltip, - isModified, - sz, +function ReadOnlyValue({ + children, + placeholder, }: { - icon: React.ReactNode, - name: string, - tooltip?: string, - isModified?: boolean, - sz: typeof sizeConfig[DesignEditableGridSize], + children: ReactNode, + placeholder?: string, }) { - const label = ( - - - {icon} - - {name} - + const hasValue = children !== ""; + + return ( + + {hasValue ? children : (placeholder ?? "—")} ); - - if (tooltip) { - return ( - - {label} - - ); - } - - return label; } -function EditableBooleanField({ - value, - onUpdate, - readOnly, - trueLabel = "Yes", - falseLabel = "No", +function EditableTextField({ + item, editMode, - sz, + size, }: { - value: boolean, - onUpdate?: (value: boolean) => Promise, - readOnly?: boolean, - trueLabel?: string, - falseLabel?: string, + item: TextItem, editMode: boolean, - sz: typeof sizeConfig[DesignEditableGridSize], + size: FieldSizeConfig, }) { - const [isUpdating, setIsUpdating] = useState(false); + const [editing, setEditing] = useState(false); + const [draftValue, setDraftValue] = useState(item.value); + const [isSaving, setIsSaving] = useState(false); + const inputRef = useRef(null); + const canEdit = item.readOnly !== true && item.onUpdate != null; - const handleChange = async (newValue: string) => { - if (!onUpdate) return; - setIsUpdating(true); + const beginEditing = () => { + if (!canEdit) { + return; + } + setDraftValue(item.value); + setEditing(true); + }; + + const cancelEditing = () => { + setDraftValue(item.value); + setEditing(false); + }; + + const save = async () => { + if (item.onUpdate == null) { + throw new Error(`Editable text item "${item.name}" requires an onUpdate handler.`); + } + + setIsSaving(true); try { - await onUpdate(newValue === "true"); + await item.onUpdate(draftValue); + setEditing(false); } finally { - setIsUpdating(false); + setIsSaving(false); } }; - if (readOnly) { + if (!canEdit && !editing) { return ( - - {value ? trueLabel : falseLabel} - +
+ {item.value} +
); } + // Idle and edit share the same shell so height/width/radius never jump — + // only the border weight and trailing actions change. return ( -
- { + const nextValue = event.target.value; + setDraftValue(item.normalizeInput?.(nextValue) ?? nextValue); + }} + onClick={(event) => event.stopPropagation()} + onKeyDown={(event) => { + if (event.key === "Enter" && !event.nativeEvent.isComposing) { + event.preventDefault(); + runAsynchronouslyWithAlert(save()); + } + if (event.key === "Escape") { + cancelEditing(); + } + }} + placeholder={item.placeholder} + value={draftValue} className={cn( - "w-full rounded-xl text-sm text-foreground", sz.height, sz.padding, - "bg-white/80 dark:bg-foreground/[0.03] border border-black/[0.08] dark:border-white/[0.06]", - "shadow-sm ring-1 ring-black/[0.08] dark:ring-white/[0.06]", - "hover:text-foreground hover:bg-white dark:hover:bg-foreground/[0.06]", - "transition-colors duration-150 hover:transition-none", - "[&>svg]:h-3.5 [&>svg]:w-3.5 [&>svg]:opacity-50", - isUpdating && "[&_span]:invisible", - !editMode && ghostFieldClasses, - !editMode && ghostFieldHoverClasses, - !editMode && "[&>svg]:opacity-0 hover:[&>svg]:opacity-50", + "min-w-0 flex-1 bg-transparent text-sm text-foreground outline-none", + "placeholder:text-muted-foreground/70", + "disabled:cursor-not-allowed disabled:opacity-50", )} - > - - - - {trueLabel} - {falseLabel} - - - {isUpdating && ( - + ) : ( + + {item.value} + + )} + {editing ? ( +
event.stopPropagation()}> + + + + + + +
+ ) : ( + )}
); } -function EditableDropdownField({ +function AsyncSelectField({ value, options, onUpdate, readOnly, - extraAction, + placeholder, + name, editMode, - sz, + size, + extraAction, }: { value: string, - options: DropdownOption[], + options: DesignEditableGridDropdownOption[], onUpdate?: (value: string) => Promise, readOnly?: boolean, - extraAction?: { label: string, onClick: () => void }, + placeholder?: string, + name: string, editMode: boolean, - sz: typeof sizeConfig[DesignEditableGridSize], + size: FieldSizeConfig, + extraAction?: DropdownItem["extraAction"], }) { const [isUpdating, setIsUpdating] = useState(false); + const selectedOption = options.find((option) => option.value === value); + const canEdit = readOnly !== true && onUpdate != null; - const handleChange = async (newValue: string) => { - if (!onUpdate) return; + if (!canEdit) { + return ( +
+ {selectedOption?.label ?? value} +
+ ); + } + + const updateValue = async (nextValue: string) => { setIsUpdating(true); try { - await onUpdate(newValue); + await onUpdate(nextValue); } finally { setIsUpdating(false); } }; - const selectedOption = options.find(option => option.value === value); - - if (readOnly) { - return ( - - {selectedOption?.label ?? value} - - ); - } - return (
{isUpdating && ( )}
); } -function CustomButtonField({ - children, - onClick, - disabled, - editMode, - sz, -}: { - children: React.ReactNode, - onClick: () => void | Promise, - disabled?: boolean, - editMode: boolean, - sz: typeof sizeConfig[DesignEditableGridSize], -}) { - return ( - - {children} - - ); -} - function CustomDropdownField({ - triggerContent, - disabled, + item, editMode, - sz, + size, }: { - triggerContent: React.ReactNode, - disabled?: boolean, + item: CustomDropdownItem, editMode: boolean, - sz: typeof sizeConfig[DesignEditableGridSize], + size: FieldSizeConfig, }) { return ( - + + + + + + {item.popoverContent} + + ); } -function GridItemValue({ item, editMode, sz }: { item: DesignEditableGridItem, editMode: boolean, sz: typeof sizeConfig[DesignEditableGridSize] }) { +function ItemValue({ + item, + editMode, + size, +}: { + item: DesignEditableGridItem, + editMode: boolean, + size: FieldSizeConfig, +}) { switch (item.type) { case "text": { - return ( - - ); + return ; } case "boolean": { + const onUpdate = item.onUpdate; return ( - await onUpdate(value === "true")} + options={[ + { value: "true", label: item.trueLabel ?? "Yes" }, + { value: "false", label: item.falseLabel ?? "No" }, + ]} + readOnly={item.readOnly} + size={size} + value={item.value ? "true" : "false"} /> ); } case "dropdown": { return ( - ); } case "custom-dropdown": { - return ( - - ); + return ; } case "custom-button": { return ( - runAsynchronouslyWithAlert(item.onClick())} + className={cn( + "flex w-full items-center justify-start gap-2 text-left font-normal", + size.controlHeight, + size.controlPadding, + size.controlText, + designEditableGridControlClassName, + editMode && "border-black/[0.18] dark:border-white/[0.22]", + item.disabled && "cursor-not-allowed opacity-50", + )} > - {item.children} - + {item.children} + ); } case "custom": { - return
{item.children}
; + return
{item.children}
; } } } -function GridItemContent({ item, isModified, editMode, sz }: { item: DesignEditableGridItem, isModified?: boolean, editMode: boolean, sz: typeof sizeConfig[DesignEditableGridSize] }) { - return ( - <> - -
- -
- +function ItemLabel({ + item, + isModified, + size, +}: { + item: DesignEditableGridItem, + isModified: boolean, + size: FieldSizeConfig, +}) { + const label = ( +
+ + {item.icon} + + + + {item.name} + {isModified && ( + + )} + + {item.description != null && ( + + {item.description} + + )} + +
); + + if (item.tooltip == null) { + return label; + } + + return {label}; } -function DesignInlineSaveDiscard({ - hasChanges, - onSave, +function SaveBar({ onDiscard, + onSave, }: { - hasChanges: boolean, - onSave: () => Promise, onDiscard: () => void, + onSave: () => Promise, }) { - const [handleSave, isSaving] = useAsyncCallback(onSave, [onSave]); - return (
- - - Discard - - - - Save - +
+ + Unsaved changes +
+
+ + Discard + + + Save changes + +
); } @@ -681,44 +654,54 @@ export function DesignEditableGrid({ className, editMode: editModeProp, deferredSave = true, - hasChanges, + hasChanges = false, onSave, onDiscard, externalModifiedKeys, + "aria-label": ariaLabel = "Editable settings", }: DesignEditableGridProps) { const contextEditMode = useDesignEditMode(); const editMode = editModeProp ?? contextEditMode; - const sz = sizeConfig[size]; + const resolvedSize = getSizeConfig(size); + + if ((onSave == null) !== (onDiscard == null)) { + throw new Error("DesignEditableGrid requires both onSave and onDiscard when either callback is provided."); + } + if (deferredSave && hasChanges && (onSave == null || onDiscard == null)) { + throw new Error("DesignEditableGrid cannot display pending changes without onSave and onDiscard callbacks."); + } const gridCols = columns === 1 - ? "grid-cols-[min-content_1fr]" - : "grid-cols-[min-content_1fr] lg:grid-cols-[min-content_1fr_min-content_1fr]"; + ? "grid-cols-[minmax(7.5rem,max-content)_minmax(0,1fr)]" + : "grid-cols-[minmax(7.5rem,max-content)_minmax(0,1fr)] lg:grid-cols-[minmax(7.5rem,max-content)_minmax(0,1fr)_minmax(7.5rem,max-content)_minmax(0,1fr)]"; return ( -
-
- {items.map((item, index) => ( - - ))} +
+
*:nth-child(4n+3)]:pl-4", + gridCols, + className, + )} + > + {items.map((item, index) => { + const isModified = item.itemKey != null && externalModifiedKeys?.has(item.itemKey) === true; + const key = item.itemKey ?? `${item.type}-${item.name}-${index}`; + return ( +
+ +
+ +
+
+ ); + })}
- {deferredSave && onSave && onDiscard && ( - + {deferredSave && hasChanges && onSave != null && onDiscard != null && ( + )}
); diff --git a/apps/dashboard/src/components/design-components/index.ts b/apps/dashboard/src/components/design-components/index.ts index 5725b0be4..c62fb9f3a 100644 --- a/apps/dashboard/src/components/design-components/index.ts +++ b/apps/dashboard/src/components/design-components/index.ts @@ -14,7 +14,8 @@ export type { } from "../../../../../packages/dashboard-ui-components/src/components/dialog"; export * from "./analytics-card"; export * from "./design-tokens"; -export * from "./editable-grid"; +export * from "./editable-grid.tsx"; +export type * from "./editable-grid.tsx"; export * from "./list"; export * from "./menu"; export * from "./select";