/* Copyright 2026 Marimo. All rights reserved. */ import { Loader2Icon } from "lucide-react"; import { type JSX, useEffect, useRef, useState } from "react"; import { z } from "zod"; import { Tooltip } from "@/components/ui/tooltip"; import type { UIElementId } from "@/core/cells/ids"; import { MarimoValueInputEvent, type MarimoValueInputEventType, } from "@/core/dom/events"; import { UI_ELEMENT_REGISTRY } from "@/core/dom/uiregistry"; import { Button } from "../../components/ui/button"; import { getUIElementObjectId, isUIElement } from "../../core/dom/ui-element"; import { cn } from "../../utils/cn"; import { createPlugin } from "../core/builder"; import { renderHTML } from "../core/RenderHTML"; import { rpc } from "../core/rpc"; import type { Setter } from "../types"; import { Banner } from "./common/error-banner"; type T = unknown; interface Data { label: string | null; elementId: UIElementId; bordered: boolean; loading: boolean; submitButtonLabel: string; submitButtonTooltip?: string; submitButtonDisabled: boolean; clearOnSubmit: boolean; showClearButton: boolean; clearButtonLabel: string; clearButtonTooltip?: string; shouldValidate?: boolean; } /** * FormPlugin * * Associates a plugin with a submit button. The associated plugin's * value updates are captured by this plugin; when the submit button * is clicked, this plugin assumes the value of the associated plugin. */ // oxlint-disable-next-line typescript/consistent-type-definitions type Functions = { validate: (req: { value?: unknown }) => Promise; }; export const FormPlugin = createPlugin("marimo-form") .withData( z.object({ label: z.string().nullable(), elementId: z.string().transform((v) => v as UIElementId), bordered: z.boolean().default(true), loading: z.boolean().default(false), submitButtonLabel: z.string().default("Submit"), submitButtonTooltip: z.string().optional(), submitButtonDisabled: z.boolean().default(false), clearOnSubmit: z.boolean().default(false), showClearButton: z.boolean().default(false), clearButtonLabel: z.string().default("Clear"), clearButtonTooltip: z.string().optional(), shouldValidate: z.boolean().optional(), }), ) .withFunctions({ validate: rpc .input(z.object({ value: z.unknown() })) .output(z.string().nullish()), }) .renderer(({ data, functions, ...rest }) => { return
; }); export interface FormWrapperProps extends Omit, Functions { children: React.ReactNode; currentValue: T; newValue: T; setValue: Setter; } export const FormWrapper = ({ children, currentValue, newValue, setValue, label, bordered, loading, submitButtonLabel, submitButtonTooltip, submitButtonDisabled, clearOnSubmit, showClearButton, clearButtonLabel, clearButtonTooltip, validate, shouldValidate, }: FormWrapperProps) => { const formDiv = useRef(null); const synchronized = newValue === currentValue; const variant = synchronized ? "secondary" : "action"; const [error, setError] = useState(null); const clear = () => { if (formDiv.current) { clearInputs(formDiv.current); } }; return ( { evt.preventDefault(); if (shouldValidate) { const response = await validate({ value: newValue }).catch( (error_) => { setError(error_.message ?? "Error validating"); }, ); if (response != null) { setError(response); return; } } setError(null); setValue(newValue); if (clearOnSubmit) { clear(); } }} >
{ // Handle enter + ctrl/meta key if (evt.key === "Enter" && (evt.ctrlKey || evt.metaKey)) { evt.preventDefault(); evt.stopPropagation(); setValue(newValue); } }} > {label === null ? null : (
{renderHTML({ html: label })}
)} {error != null && ( {error ?? "Invalid input"} )}
{children}
{showClearButton && withTooltip( , clearButtonTooltip, )} {withTooltip( , submitButtonTooltip, )}
); }; interface FormProps extends Data, Functions { value: T; setValue: Setter; children?: React.ReactNode | undefined; } const Form = ({ elementId, value, setValue, children, validate, ...data }: FormProps) => { // The internal (buffered) value is initialized as the current value of the // wrapped plugin; this buffered value is in contrast to the actual // value of the plugin, which is the value of the wrapped plugin when // the submit button was last clicked/activated. const [internalValue, setInternalValue] = useState( UI_ELEMENT_REGISTRY.lookupValue(elementId), ); // The Form may be rendered before the child plugin is, so after mount // we lookup the plugin once again. useEffect(() => { setInternalValue(UI_ELEMENT_REGISTRY.lookupValue(elementId)); }, [elementId]); // Edge case: the Form plugin may be re-created in Python with the same // wrapped `elementId`, meaning the value of the wrapped element // can change without the plugin generating an event const wrappedValue = UI_ELEMENT_REGISTRY.lookupValue(elementId); // Use Object.is as there can be NaN values if (!Object.is(wrappedValue, internalValue)) { setInternalValue(wrappedValue); } useEffect(() => { // Spy on when the plugin generates an event (MarimoValueInputEvent) const handleUpdate = (e: MarimoValueInputEventType) => { const target = e.detail.element; if (target === null || !(target instanceof Node)) { return; } const objectId = getUIElementObjectId(target); if (objectId === elementId) { setInternalValue(e.detail.value); } }; document.addEventListener(MarimoValueInputEvent.TYPE, handleUpdate); return () => { document.removeEventListener(MarimoValueInputEvent.TYPE, handleUpdate); }; }, [elementId, setValue]); return ( currentValue={value} newValue={internalValue} setValue={setValue} validate={validate} {...data} > {children} ); }; function withTooltip(element: JSX.Element, tooltip?: string) { if (tooltip) { return {element}; } return element; } /** * Traverse the input elements and find all IUIElement instances and reset them. */ function clearInputs(element: Element) { if (!(element instanceof HTMLElement)) { return; } // If the element has a shadowRoot, traverse its children if (element.shadowRoot) { [...element.shadowRoot.children].forEach(clearInputs); } // Traverse the children of the element in the light DOM [...element.children].forEach(clearInputs); if (isUIElement(element)) { element.reset(); } }