diff --git a/src/app/content/ui/editable-error.tsx b/src/app/content/ui/editable-error.tsx new file mode 100644 index 00000000..bc6e9095 --- /dev/null +++ b/src/app/content/ui/editable-error.tsx @@ -0,0 +1,24 @@ +import { + Editable, + EditableError, + EditableInput, + EditablePreview, +} from "@/components/ui/editable"; + +export default function EditableErrorDemo() { + const error = "This field is required"; + + return ( +
+ + + + {error} + +
+ ); +} diff --git a/src/app/demo/[name]/ui/editable.tsx b/src/app/demo/[name]/ui/editable.tsx index 0bf12ccc..e2316275 100644 --- a/src/app/demo/[name]/ui/editable.tsx +++ b/src/app/demo/[name]/ui/editable.tsx @@ -23,5 +23,6 @@ export const editable = { }, components: { Textarea: { component: "editable-textarea" }, + "Editable with error": { component: "editable-error" }, }, }; diff --git a/src/components/ui/editable.tsx b/src/components/ui/editable.tsx index 25d237b3..db18d5e8 100644 --- a/src/components/ui/editable.tsx +++ b/src/components/ui/editable.tsx @@ -57,6 +57,8 @@ interface UseEditableProps { selectAllOnFocus?: boolean; /** Activation mode: 'click' or 'dblclick' */ activationMode?: ActivationMode; + /** Error message to display */ + hasError?: boolean; /** Callback when value is submitted */ onSubmit?: (value: string) => void; /** Callback when value changes */ @@ -85,6 +87,7 @@ function useEditable(props: UseEditableProps = {}): UseEditableReturn { startWithEditView = false, selectAllOnFocus = true, activationMode = "click", + hasError = false, onSubmit, onChange, onValueChange, @@ -92,7 +95,9 @@ function useEditable(props: UseEditableProps = {}): UseEditableReturn { onEdit, } = props; - const [isEditing, setIsEditing] = React.useState(startWithEditView); + const [isEditing, setIsEditing] = React.useState( + startWithEditView || hasError, + ); const [internalValue, setInternalValue] = React.useState(defaultValue); const [previousValue, setPreviousValue] = React.useState(defaultValue); const inputRef = React.useRef( @@ -114,14 +119,14 @@ function useEditable(props: UseEditableProps = {}): UseEditableReturn { if (!isControlled) { setInternalValue(previousValue); } - setIsEditing(false); + setIsEditing(hasError || false); onCancel?.(previousValue); - }, [isControlled, previousValue, onCancel]); + }, [isControlled, previousValue, onCancel, hasError]); const submitEdit = React.useCallback(() => { - setIsEditing(false); + setIsEditing(hasError || false); onSubmit?.(value); - }, [value, onSubmit]); + }, [value, onSubmit, hasError]); const handleChange = React.useCallback( (newValue: string) => { @@ -169,7 +174,7 @@ function useEditable(props: UseEditableProps = {}): UseEditableReturn { // Editable Root Component -const editableVariants = cva("inline-flex flex-col gap-1", { +const editableVariants = cva("inline-flex flex-col gap-1 relative", { variants: { size: { sm: "text-sm", @@ -203,6 +208,8 @@ interface EditableProps selectAllOnFocus?: boolean; /** Activation mode: 'click' or 'dblclick' */ activationMode?: ActivationMode; + /** Error message to display */ + hasError?: boolean; /** Callback when value is submitted */ onSubmit?: (value: string) => void; /** Callback when value changes */ @@ -227,6 +234,7 @@ function Editable({ startWithEditView = false, selectAllOnFocus = true, activationMode = "click", + hasError = false, onSubmit, onChange, onValueChange, @@ -235,7 +243,9 @@ function Editable({ children, ...props }: EditableProps) { - const [isEditing, setIsEditing] = React.useState(startWithEditView); + const [isEditing, setIsEditing] = React.useState( + startWithEditView || hasError, + ); const [internalValue, setInternalValue] = React.useState(defaultValue); const [previousValue, setPreviousValue] = React.useState(defaultValue); const inputRef = React.useRef( @@ -257,14 +267,14 @@ function Editable({ if (!isControlled) { setInternalValue(previousValue); } - setIsEditing(false); + setIsEditing(hasError || false); onCancel?.(previousValue); - }, [isControlled, previousValue, onCancel]); + }, [isControlled, previousValue, onCancel, hasError]); const submitEdit = React.useCallback(() => { - setIsEditing(false); + setIsEditing(hasError || false); onSubmit?.(value); - }, [value, onSubmit]); + }, [value, onSubmit, hasError]); const handleChange = React.useCallback( (newValue: string) => { @@ -355,7 +365,7 @@ const editablePreviewVariants = cva( [ "cursor-text rounded-md px-2 py-1 transition-colors", "hover:bg-neutral-bg", - "min-h-[2rem] flex items-center whitespace-pre-line break-words", + "min-h-8 flex items-center whitespace-pre-line break-words", ].join(" "), { variants: { @@ -474,7 +484,7 @@ function EditableInput({ className, ...props }: EditableInputProps) { } }} className={cn( - "w-full border-2 bg-transparent dark:bg-transparent focus-visible:ring-0 focus-visible:border-primary transition-colors", + "w-full border-2 bg-transparent h-8 dark:bg-transparent focus-visible:ring-0 focus-visible:border-primary transition-colors", className, )} {...props} @@ -649,6 +659,47 @@ function EditableSubmitTrigger({ ); } +interface EditableErrorProps extends React.HTMLAttributes { + errors?: { message?: string }[]; +} + +function EditableError({ + errors, + children, + className, + ...props +}: EditableErrorProps) { + const errorMessages = errors?.filter(Boolean) || []; + + if (errorMessages.length === 0 && !children) { + return null; + } + + return ( +
+ {children || + (errorMessages.length === 1 ? ( + {errorMessages[0]?.message} + ) : ( +
    + {errorMessages.map((error, index) => ( +
  • {error?.message}
  • + ))} +
+ ))} +
+ ); +} + export { Editable, EditableRootProvider, @@ -659,6 +710,7 @@ export { EditableEditTrigger, EditableCancelTrigger, EditableSubmitTrigger, + EditableError, editableVariants, editablePreviewVariants, useEditable, diff --git a/src/lib/docsite/docsite-registry.ts b/src/lib/docsite/docsite-registry.ts index 6252ed14..8e2af2db 100644 --- a/src/lib/docsite/docsite-registry.ts +++ b/src/lib/docsite/docsite-registry.ts @@ -168,6 +168,7 @@ import CircularProgressDemo from "@/app/content/ui/circular-progress"; import CircularProgressWithTextDemo from "@/app/content/ui/circular-progress-text"; import CircularProgressVariantsDemo from "@/app/content/ui/circular-progress-variants"; import EditableDemo from "@/app/content/ui/editable"; +import EditableErrorDemo from "@/app/content/ui/editable-error"; import EditableTextareaDemo from "@/app/content/ui/editable-textarea"; import FieldDemo from "@/app/content/ui/field"; import FieldCheckboxDemo from "@/app/content/ui/field-checkbox"; @@ -184,6 +185,11 @@ import FieldWithSeparatorDemo from "@/app/content/ui/field-separator"; import FieldSmallDemo from "@/app/content/ui/field-small"; import FieldSwitchDemo from "@/app/content/ui/field-switch"; import FieldTextareaDemo from "@/app/content/ui/field-textarea"; +import FilterDemo from "@/app/content/ui/filter"; +import FilterInputDemo from "@/app/content/ui/filter-input"; +import FilterMultiSelectDemo from "@/app/content/ui/filter-multi-select"; +import FilterSingleSelectDemo from "@/app/content/ui/filter-single-select"; +import FilterToggleDemo from "@/app/content/ui/filter-toggle"; import InputGroupDemo from "@/app/content/ui/input-group"; import InputGroupDropdownDemo from "@/app/content/ui/input-group-dropdown"; import InputGroupSearchDemo from "@/app/content/ui/input-group-search"; @@ -202,11 +208,6 @@ import TimelineDemo from "@/app/content/ui/timeline"; import TimelineConnectorVariantsDemo from "@/app/content/ui/timeline-connector-variants"; import TimelineSizesDemo from "@/app/content/ui/timeline-sizes"; import TimelineVariantsDemo from "@/app/content/ui/timeline-variants"; -import FilterDemo from "@/app/content/ui/filter"; -import FilterInputDemo from "@/app/content/ui/filter-input"; -import FilterSingleSelectDemo from "@/app/content/ui/filter-single-select"; -import FilterMultiSelectDemo from "@/app/content/ui/filter-multi-select"; -import FilterToggleDemo from "@/app/content/ui/filter-toggle"; export interface DocsiteRegistryEntry { name: string; @@ -606,6 +607,11 @@ export const docsiteRegistry: Record = { path: "src/app/content/ui/editable-textarea.tsx", component: EditableTextareaDemo, }, + "editable-error": { + name: "editable-error", + path: "src/app/content/ui/editable-error.tsx", + component: EditableErrorDemo, + }, "empty-states-no-results": { name: "empty-states-no-results", path: "src/app/content/ui/empty-states-no-search-results.tsx", @@ -736,31 +742,31 @@ export const docsiteRegistry: Record = { path: "src/app/content/ui/field-input-types.tsx", component: FieldInputTypesDemo, }, - "filter": { - name: "filter", - path: "src/app/content/ui/filter.tsx", - component: FilterDemo, - }, - "filter-input": { - name: "filter-input", - path: "src/app/content/ui/filter-input.tsx", - component: FilterInputDemo, - }, - "filter-single-select": { - name: "filter-single-select", - path: "src/app/content/ui/filter-single-select.tsx", - component: FilterSingleSelectDemo, - }, - "filter-multi-select": { - name: "filter-multi-select", - path: "src/app/content/ui/filter-multi-select.tsx", - component: FilterMultiSelectDemo, - }, - "filter-toggle": { - name: "filter-toggle", - path: "src/app/content/ui/filter-toggle.tsx", - component: FilterToggleDemo, - }, + filter: { + name: "filter", + path: "src/app/content/ui/filter.tsx", + component: FilterDemo, + }, + "filter-input": { + name: "filter-input", + path: "src/app/content/ui/filter-input.tsx", + component: FilterInputDemo, + }, + "filter-single-select": { + name: "filter-single-select", + path: "src/app/content/ui/filter-single-select.tsx", + component: FilterSingleSelectDemo, + }, + "filter-multi-select": { + name: "filter-multi-select", + path: "src/app/content/ui/filter-multi-select.tsx", + component: FilterMultiSelectDemo, + }, + "filter-toggle": { + name: "filter-toggle", + path: "src/app/content/ui/filter-toggle.tsx", + component: FilterToggleDemo, + }, icon: { name: "icon", path: "src/app/content/ui/icon-component.tsx", diff --git a/src/lib/right-sidebar-metadata.ts b/src/lib/right-sidebar-metadata.ts index dcab8df7..c35c448d 100644 --- a/src/lib/right-sidebar-metadata.ts +++ b/src/lib/right-sidebar-metadata.ts @@ -337,7 +337,10 @@ export const rightSidebarMetadata: Record = { { id: "examples", title: "Examples", - children: [{ id: "editable-textarea", title: "Textarea" }], + children: [ + { id: "editable-textarea", title: "Textarea" }, + { id: "editable-error", title: "Editable with errors" }, + ], }, ], },