♻️ Move editable components to shared UI package

This commit is contained in:
Baptiste Arnaud 2026-03-24 16:48:49 +01:00
parent a2ee915628
commit ece99ba625
No known key found for this signature in database
14 changed files with 216 additions and 160 deletions

View File

@ -1,106 +0,0 @@
import { Input, type InputProps } from "@typebot.io/ui/components/Input";
import { cn } from "@typebot.io/ui/lib/cn";
import {
type ButtonHTMLAttributes,
forwardRef,
type HTMLAttributes,
useRef,
useState,
} from "react";
import { useOutsideClick } from "@/hooks/useOutsideClick";
export type SingleLineEditableProps = {
className?: string;
input?: Omit<InputProps, "value">;
preview?: ButtonHTMLAttributes<HTMLButtonElement>;
common?: HTMLAttributes<HTMLButtonElement | HTMLInputElement>;
defaultEdit?: boolean;
value?: string;
defaultValue?: string;
onValueCommit: (value: string) => void;
onClick?: (e: React.MouseEvent<HTMLDivElement>) => void;
children?: React.ReactNode;
};
export const SingleLineEditable = forwardRef<
HTMLDivElement,
SingleLineEditableProps
>(
(
{
input,
preview,
common,
value,
defaultValue,
defaultEdit,
onValueCommit,
children,
...props
},
ref,
) => {
const inputRef = useRef<HTMLInputElement>(null);
const [isEditing, setIsEditing] = useState(defaultEdit ?? false);
const [currentValue, setCurrentValue] = useState(defaultValue ?? "");
const commitValue = () => {
setIsEditing(false);
onValueCommit(value ?? currentValue);
};
useOutsideClick({
ref: inputRef,
capture: true,
handler: commitValue,
});
return (
<div {...props} ref={ref}>
{isEditing ? (
<Input
{...input}
ref={inputRef}
onKeyDownCapture={(e) => {
input?.onKeyDownCapture?.(e);
if (e.key === "Enter") commitValue();
if (e.key === "Escape") setIsEditing(false);
}}
size="none"
value={value ?? currentValue}
autoFocus
onFocus={(e) => e.currentTarget.select()}
onValueChange={(value, e) => {
setCurrentValue(value);
input?.onValueChange?.(value, e);
}}
className={cn(
"p-1 rounded-md border",
common?.className,
input?.className,
)}
/>
) : (
<button
{...preview}
type="button"
onClick={(e) => {
preview?.onClick?.(e);
if (e.defaultPrevented) return;
setIsEditing(true);
}}
className={cn(
"hover:bg-gray-3 inline-flex w-full p-1 border border-transparent rounded-md cursor-pointer",
common?.className,
preview?.className,
)}
>
{value ?? currentValue}
</button>
)}
{children}
</div>
);
},
);
SingleLineEditable.displayName = "SingleLineEditable";

View File

@ -8,10 +8,10 @@ import { convertStrToList } from "@typebot.io/lib/convertStrToList";
import { isEmpty } from "@typebot.io/lib/utils";
import { Button } from "@typebot.io/ui/components/Button";
import { Popover } from "@typebot.io/ui/components/Popover";
import { SingleLineEditable } from "@typebot.io/ui/components/SingleLineEditable";
import { Settings01Icon } from "@typebot.io/ui/icons/Settings01Icon";
import { cx } from "@typebot.io/ui/lib/cva";
import { useState } from "react";
import { SingleLineEditable } from "@/components/SingleLineEditable";
import { useTypebot } from "@/features/editor/providers/TypebotProvider";
import { useGraph } from "@/features/graph/providers/GraphProvider";
import { ButtonsItemSettings } from "./ButtonsItemSettings";

View File

@ -6,21 +6,21 @@ import type {
} from "@typebot.io/blocks-core/schemas/items/schema";
import type { CardsItem } from "@typebot.io/blocks-inputs/cards/schema";
import { Button } from "@typebot.io/ui/components/Button";
import {
MultiLineEditable,
type MultiLineEditableProps,
} from "@typebot.io/ui/components/MultiLineEditable";
import { Popover } from "@typebot.io/ui/components/Popover";
import {
SingleLineEditable,
type SingleLineEditableProps,
} from "@typebot.io/ui/components/SingleLineEditable";
import { Settings01Icon } from "@typebot.io/ui/icons/Settings01Icon";
import { cn } from "@typebot.io/ui/lib/cn";
import { cx } from "@typebot.io/ui/lib/cva";
import { useState } from "react";
import { ImageOrPlaceholder } from "@/components/ImageOrPlaceholder";
import { ImageUploadContent } from "@/components/ImageUploadContent/ImageUploadContent";
import {
MultiLineEditable,
type MultiLineEditableProps,
} from "@/components/MultiLineEditable";
import {
SingleLineEditable,
type SingleLineEditableProps,
} from "@/components/SingleLineEditable";
import {
GhostableItem,
StacksWithGhostableItems,

View File

@ -1,7 +1,7 @@
import { useTranslate } from "@tolgee/react";
import { SingleLineEditable } from "@typebot.io/ui/components/SingleLineEditable";
import { Tooltip } from "@typebot.io/ui/components/Tooltip";
import { useState } from "react";
import { SingleLineEditable } from "@/components/SingleLineEditable";
type EditableProps = {
defaultName: string;

View File

@ -5,6 +5,7 @@ import { Alert } from "@typebot.io/ui/components/Alert";
import { AlertDialog } from "@typebot.io/ui/components/AlertDialog";
import { Button, buttonVariants } from "@typebot.io/ui/components/Button";
import { Menu } from "@typebot.io/ui/components/Menu";
import { SingleLineEditable } from "@typebot.io/ui/components/SingleLineEditable";
import { Skeleton } from "@typebot.io/ui/components/Skeleton";
import { useOpenControls } from "@typebot.io/ui/hooks/useOpenControls";
import { Folder01SolidIcon } from "@typebot.io/ui/icons/Folder01SolidIcon";
@ -13,7 +14,6 @@ import { TriangleAlertIcon } from "@typebot.io/ui/icons/TriangleAlertIcon";
import { cn } from "@typebot.io/ui/lib/cn";
import { useRouter } from "next/router";
import { memo, useMemo, useRef, useState } from "react";
import { SingleLineEditable } from "@/components/SingleLineEditable";
import { orpc } from "@/lib/queryClient";
import { useTypebotDnd } from "../TypebotDndProvider";

View File

@ -1,11 +1,11 @@
import type { GroupV6 } from "@typebot.io/groups/schemas";
import { isEmpty, isNotDefined } from "@typebot.io/lib/utils";
import { ContextMenu } from "@typebot.io/ui/components/ContextMenu";
import { SingleLineEditable } from "@typebot.io/ui/components/SingleLineEditable";
import { cx } from "@typebot.io/ui/lib/cva";
import { useDrag } from "@use-gesture/react";
import { useEffect, useRef, useState } from "react";
import { useShallow } from "zustand/react/shallow";
import { SingleLineEditable } from "@/components/SingleLineEditable";
import { useEditor } from "@/features/editor/providers/EditorProvider";
import { useTypebot } from "@/features/editor/providers/TypebotProvider";
import { groupWidth } from "@/features/graph/constants";

View File

@ -10,6 +10,7 @@ import { Field } from "@typebot.io/ui/components/Field";
import { Input } from "@typebot.io/ui/components/Input";
import { MoreInfoTooltip } from "@typebot.io/ui/components/MoreInfoTooltip";
import { Popover } from "@typebot.io/ui/components/Popover";
import { SingleLineEditable } from "@typebot.io/ui/components/SingleLineEditable";
import { Switch } from "@typebot.io/ui/components/Switch";
import { useOpenControls } from "@typebot.io/ui/hooks/useOpenControls";
import { Cancel01Icon } from "@typebot.io/ui/icons/Cancel01Icon";
@ -19,7 +20,6 @@ import { TrashIcon } from "@typebot.io/ui/icons/TrashIcon";
import type { Variable } from "@typebot.io/variables/schemas";
import { useDrag } from "@use-gesture/react";
import { type FormEvent, useState } from "react";
import { SingleLineEditable } from "@/components/SingleLineEditable";
import { toast } from "@/lib/toast";
import { headerHeight } from "../../editor/constants";
import { useTypebot } from "../../editor/providers/TypebotProvider";

View File

@ -1,6 +1,6 @@
import { SingleLineEditable } from "@typebot.io/ui/components/SingleLineEditable";
import { useState } from "react";
import { CopyButton } from "@/components/CopyButton";
import { SingleLineEditable } from "@/components/SingleLineEditable";
type EditableUrlProps = {
hostname: string;

View File

@ -44,17 +44,19 @@ export const handleSaveThemeTemplate = async ({
},
});
const themeTemplate = (existingThemeTemplate
? await prisma.themeTemplate.update({
where: { id: themeTemplateId },
data,
})
: await prisma.themeTemplate.create({
data: {
...data,
workspaceId,
},
})) as ThemeTemplate;
const themeTemplate = (
existingThemeTemplate
? await prisma.themeTemplate.update({
where: { id: themeTemplateId },
data,
})
: await prisma.themeTemplate.create({
data: {
...data,
workspaceId,
},
})
) as ThemeTemplate;
return {
themeTemplate,

View File

@ -602,7 +602,7 @@
},
"packages/embeds/js": {
"name": "@typebot.io/js",
"version": "0.9.22",
"version": "0.9.23",
"devDependencies": {
"@ai-sdk/ui-utils": "^1.2.11",
"@ark-ui/solid": "^5.19.0",
@ -637,7 +637,7 @@
},
"packages/embeds/react": {
"name": "@typebot.io/react",
"version": "0.9.22",
"version": "0.9.23",
"dependencies": {
"@typebot.io/js": "workspace:*",
"react": "^19.2.4",

View File

@ -37,7 +37,10 @@
],
"bundle": true,
"thirdParty": true,
"external": ["react", "react/jsx-runtime"],
"external": [
"react",
"react/jsx-runtime"
],
"declaration": true
},
"configurations": {

View File

@ -1,12 +1,18 @@
import { Textarea } from "@typebot.io/ui/components/Textarea";
import { cn } from "@typebot.io/ui/lib/cn";
import { type ButtonHTMLAttributes, useEffect, useRef, useState } from "react";
import { useOutsideClick } from "@/hooks/useOutsideClick";
import {
type ButtonHTMLAttributes,
type TextareaHTMLAttributes,
useEffect,
useRef,
useState,
} from "react";
import { useOutsideClick } from "../hooks/useOutsideClick";
import { cn } from "../lib/cn";
import { Textarea } from "./Textarea";
export type MultiLineEditableProps = {
className?: string;
input?: Omit<
React.TextareaHTMLAttributes<HTMLTextAreaElement>,
TextareaHTMLAttributes<HTMLTextAreaElement>,
"value" | "onChange"
> & {
onValueChange?: (value: string) => void;
@ -17,7 +23,7 @@ export type MultiLineEditableProps = {
onValueCommit: (value: string) => void;
};
const ADDITIONAL_FOCUS_HEIGHT = 20 as const;
const additionalFocusHeight = 20;
export const MultiLineEditable = ({
input,
@ -28,23 +34,26 @@ export const MultiLineEditable = ({
...props
}: MultiLineEditableProps) => {
const textareaRef = useRef<HTMLTextAreaElement>(null);
const autoResize = (textarea: HTMLTextAreaElement) => {
if (!textarea) return;
textarea.style.height = "auto";
textarea.style.height = `${textarea.scrollHeight + ADDITIONAL_FOCUS_HEIGHT}px`;
};
const handleMouseWheel = (e: WheelEvent) => {
e.stopPropagation();
const autoResize = (textareaElement: HTMLTextAreaElement) => {
textareaElement.style.height = "auto";
textareaElement.style.height = `${textareaElement.scrollHeight + additionalFocusHeight}px`;
};
useEffect(() => {
textareaRef.current?.addEventListener("wheel", handleMouseWheel);
const textareaElement = textareaRef.current;
if (!textareaElement) return;
return () => {
textareaRef.current?.addEventListener("wheel", handleMouseWheel);
const stopMouseWheelPropagation = (event: WheelEvent) => {
event.stopPropagation();
};
});
textareaElement.addEventListener("wheel", stopMouseWheelPropagation);
return () => {
textareaElement.removeEventListener("wheel", stopMouseWheelPropagation);
};
}, []);
const [isEditing, setIsEditing] = useState(defaultEdit);
const commitValue = () => {
@ -63,18 +72,18 @@ export const MultiLineEditable = ({
{isEditing ? (
<Textarea
{...input}
onKeyDownCapture={(e) => {
input?.onKeyDownCapture?.(e);
if (e.key === "Enter" && e.metaKey) commitValue();
if (e.key === "Escape") setIsEditing(false);
onKeyDownCapture={(event) => {
input?.onKeyDownCapture?.(event);
if (event.key === "Enter" && event.metaKey) commitValue();
if (event.key === "Escape") setIsEditing(false);
}}
ref={textareaRef}
size="none"
value={value}
className={cn("px-1 py-1 rounded-md border w-full", input?.className)}
onFocus={(e) => {
autoResize(e.currentTarget);
e.currentTarget.select();
className={cn("w-full rounded-md border px-1 py-1", input?.className)}
onFocus={(event) => {
autoResize(event.currentTarget);
event.currentTarget.select();
}}
onValueChange={input?.onValueChange}
autoFocus
@ -89,7 +98,7 @@ export const MultiLineEditable = ({
setIsEditing(true);
}}
className={cn(
"hover:bg-gray-3 inline-flex w-full p-1 border border-transparent rounded-md whitespace-pre-line cursor-pointer",
"hover:bg-gray-3 inline-flex w-full cursor-pointer whitespace-pre-line rounded-md border border-transparent p-1",
preview?.className,
)}
>

View File

@ -0,0 +1,99 @@
import {
type ButtonHTMLAttributes,
type HTMLAttributes,
type MouseEvent,
type ReactNode,
useRef,
useState,
} from "react";
import { useOutsideClick } from "../hooks/useOutsideClick";
import { cn } from "../lib/cn";
import type { InputProps } from "./Input";
import { Input } from "./Input";
export type SingleLineEditableProps = {
className?: string;
input?: Omit<InputProps, "value">;
preview?: ButtonHTMLAttributes<HTMLButtonElement>;
common?: HTMLAttributes<HTMLButtonElement | HTMLInputElement>;
defaultEdit?: boolean;
value?: string;
defaultValue?: string;
onValueCommit: (value: string) => void;
onClick?: (event: MouseEvent<HTMLDivElement>) => void;
children?: ReactNode;
};
export const SingleLineEditable = ({
input,
preview,
common,
value,
defaultValue,
defaultEdit,
onValueCommit,
children,
...props
}: SingleLineEditableProps) => {
const inputRef = useRef<HTMLInputElement>(null);
const [isEditing, setIsEditing] = useState(defaultEdit ?? false);
const [currentValue, setCurrentValue] = useState(defaultValue ?? "");
const commitValue = () => {
setIsEditing(false);
onValueCommit(value ?? currentValue);
};
useOutsideClick({
ref: inputRef,
capture: true,
handler: commitValue,
});
return (
<div {...props}>
{isEditing ? (
<Input
{...input}
ref={inputRef}
onKeyDownCapture={(event) => {
input?.onKeyDownCapture?.(event);
if (event.key === "Enter") commitValue();
if (event.key === "Escape") setIsEditing(false);
}}
size="none"
value={value ?? currentValue}
autoFocus
onFocus={(event) => event.currentTarget.select()}
onValueChange={(updatedValue, event) => {
setCurrentValue(updatedValue);
input?.onValueChange?.(updatedValue, event);
}}
className={cn(
"rounded-md border p-1",
common?.className,
input?.className,
)}
/>
) : (
<button
{...preview}
type="button"
onClick={(event) => {
preview?.onClick?.(event);
if (event.defaultPrevented) return;
setIsEditing(true);
}}
className={cn(
"hover:bg-gray-3 inline-flex w-full cursor-pointer rounded-md border border-transparent p-1",
common?.className,
preview?.className,
)}
>
{value ?? currentValue}
</button>
)}
{children}
</div>
);
};

View File

@ -0,0 +1,49 @@
import type { RefObject } from "react";
import { useEffect } from "react";
type Handler = (event: MouseEvent) => void;
type Props<T> = {
ref: RefObject<T | null>;
handler: Handler;
capture?: boolean;
isEnabled?: boolean;
ignoreSelectors?: string[];
};
export const useOutsideClick = <T extends HTMLElement = HTMLElement>({
ref,
handler,
capture,
isEnabled,
ignoreSelectors,
}: Props<T>): void => {
useEffect(() => {
if (isEnabled === false) return;
const triggerHandlerIfOutside = (event: MouseEvent) => {
if (!(event.target instanceof Node)) return;
const clickedNode = event.target;
const clickedElement =
event.target instanceof Element ? event.target : undefined;
const element = ref.current;
if (
!element ||
element.contains(clickedNode) ||
ignoreSelectors?.some((selector) => clickedElement?.closest(selector))
) {
return;
}
handler(event);
};
document.addEventListener("pointerdown", triggerHandlerIfOutside, {
capture,
});
return () => {
document.removeEventListener("pointerdown", triggerHandlerIfOutside, {
capture,
});
};
}, [capture, handler, ignoreSelectors, isEnabled, ref]);
};