/* Copyright 2026 Marimo. All rights reserved. */ import React, { useRef, useState } from "react"; import { Input } from "@/components/ui/input"; import { Tooltip } from "@/components/ui/tooltip"; import { getCellNames, useCellActions } from "@/core/cells/cells"; import type { CellId } from "@/core/cells/ids"; import { getValidName, isInternalCellName, normalizeName, } from "@/core/cells/names"; import { useOnMount } from "@/hooks/useLifecycle"; import { cn } from "@/utils/cn"; import { Events } from "@/utils/events"; interface Props extends Omit< React.InputHTMLAttributes, "onChange" > { value: string; onChange: (newName: string) => void; placeholder?: string; onEnterKey?: () => void; } export const NameCellInput: React.FC = ({ value, onChange, placeholder, onEnterKey, ...props }) => { const ref = useRef(null); const inputProps = useCellNameInput(value, onChange); // Custom onBlur without React's synthetic events // See https://github.com/facebook/react/issues/12363 useOnMount(() => { const onBlur = inputProps.onBlur; const input = ref.current; if (!input) { return; } input.addEventListener("blur", onBlur); return () => { input.removeEventListener("blur", onBlur); }; }); return ( { Events.stopPropagation()(e); onEnterKey?.(); })} {...props} /> ); }; export const NameCellContentEditable: React.FC<{ cellId: CellId; value: string; className: string; }> = ({ value, cellId, className }) => { const { updateCellName } = useCellActions(); const inputProps = useCellNameInput(value, (newName) => updateCellName({ cellId, name: newName }), ); // If the name is the default, don't render the content editable if (isInternalCellName(value)) { return null; } return ( { // Prevent all key presses from triggering hotkeys e.stopPropagation(); // On Enter, blur the input to commit the change if (e.key === "Enter" && e.target instanceof HTMLElement) { e.target.blur(); } }} > {value} ); }; function useCellNameInput(value: string, onChange: (newName: string) => void) { const [internalValue, setInternalValue] = useState(value); const [focusing, setFocusing] = useState(false); const commit = (newValue: string) => { // No change if (newValue === value) { return; } // Empty if (!newValue || isInternalCellName(newValue)) { onChange(newValue); return; } // Get unique name const validName = getValidName(newValue, getCellNames()); onChange(validName); }; return { value: isInternalCellName(internalValue) ? "" : internalValue, focusing, onChange: (evt: React.ChangeEvent) => { const newValue = evt.target.value; const normalized = normalizeName(newValue); setInternalValue(normalized); }, onBlur: (evt: Pick) => { if (evt.target instanceof HTMLInputElement) { const newValue = evt.target.value; commit(normalizeName(newValue)); } else if (evt.target instanceof HTMLSpanElement) { const newValue = evt.target.innerText.trim(); commit(normalizeName(newValue)); // Scroll to the left after committing to make sure showing the start of the cell name evt.target.scrollLeft = 0; setFocusing(false); } }, onFocus: () => { setFocusing(true); }, }; }