🔧 Migrate biome rules: interactive semantics checks

This commit is contained in:
Baptiste Arnaud 2026-03-16 18:36:59 +01:00
parent 90ec449168
commit d1e2781caf
No known key found for this signature in database
40 changed files with 289 additions and 332 deletions

View File

@ -179,16 +179,10 @@ type UnsplashImageProps = {
};
const UnsplashImage = ({ image, onClick }: UnsplashImageProps) => {
const [isImageHovered, setIsImageHovered] = useState(false);
const { user, urls, alt_description } = image;
return (
<div
className="relative h-full"
onMouseEnter={() => setIsImageHovered(true)}
onMouseLeave={() => setIsImageHovered(false)}
>
<div className="group relative h-full">
<button
type="button"
className="size-full rounded-md cursor-pointer p-0 border-none bg-transparent"
@ -202,8 +196,7 @@ const UnsplashImage = ({ image, onClick }: UnsplashImageProps) => {
</button>
<div
className={cx(
"absolute px-2 rounded-md bottom-0 left-0 bg-black/50 opacity-0 transition-opacity duration-200",
isImageHovered ? "opacity-100" : "opacity-0",
"absolute px-2 rounded-md bottom-0 left-0 bg-black/50 opacity-0 transition-opacity duration-200 group-hover:opacity-100 group-focus-within:opacity-100",
)}
>
<TextLink

View File

@ -1,6 +1,6 @@
import { Textarea } from "@typebot.io/ui/components/Textarea";
import { cn } from "@typebot.io/ui/lib/cn";
import { type HTMLAttributes, useEffect, useRef, useState } from "react";
import { type ButtonHTMLAttributes, useEffect, useRef, useState } from "react";
import { useOutsideClick } from "@/hooks/useOutsideClick";
export type MultiLineEditableProps = {
@ -11,7 +11,7 @@ export type MultiLineEditableProps = {
> & {
onValueChange?: (value: string) => void;
};
preview?: HTMLAttributes<HTMLSpanElement>;
preview?: ButtonHTMLAttributes<HTMLButtonElement>;
defaultEdit: boolean;
value: string;
onValueCommit: (value: string) => void;
@ -80,14 +80,12 @@ export const MultiLineEditable = ({
autoFocus
/>
) : (
<span
<button
{...preview}
onClick={() => setIsEditing(true)}
onKeyDown={(event) => {
preview?.onKeyDown?.(event);
type="button"
onClick={(event) => {
preview?.onClick?.(event);
if (event.defaultPrevented) return;
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
setIsEditing(true);
}}
className={cn(
@ -96,7 +94,7 @@ export const MultiLineEditable = ({
)}
>
{value}
</span>
</button>
)}
</div>
);

View File

@ -42,7 +42,6 @@ export const PrimitiveList = <T extends number | string | boolean>({
onItemsChange,
}: Props<T>) => {
const [items, setItems] = useState<ItemWithId<T>[]>();
const [showDeleteIndex, setShowDeleteIndex] = useState<number | null>(null);
useEffect(() => {
if (items) return;
@ -79,14 +78,9 @@ export const PrimitiveList = <T extends number | string | boolean>({
onItemsChange(removeIdFromItems([...newItems]));
};
const handleMouseEnter = (itemIndex: number) => () =>
setShowDeleteIndex(itemIndex);
const handleCellChange = (itemIndex: number) => (item: T) =>
updateItem(itemIndex, item);
const handleMouseLeave = () => setShowDeleteIndex(null);
return (
<div className="flex flex-col gap-0">
{items?.map((item, itemIndex) => (
@ -96,27 +90,23 @@ export const PrimitiveList = <T extends number | string | boolean>({
)}
<div
className={cx(
"flex relative justify-center pb-4",
"group/item flex relative justify-center pb-4",
itemIndex !== 0 && ComponentBetweenItems ? "mt-4" : "mt-0",
)}
onMouseEnter={handleMouseEnter(itemIndex)}
onMouseLeave={handleMouseLeave}
>
{children({
item: item.value as T,
onItemChange: handleCellChange(itemIndex),
})}
{showDeleteIndex === itemIndex && (
<Button
variant="secondary"
className="size-6 animate-in fade-in-0 absolute left-[-15px] top-[-15px] z-10"
size="icon"
aria-label="Remove item"
onClick={deleteItem(itemIndex)}
>
<TrashIcon />
</Button>
)}
<Button
variant="secondary"
className="size-6 absolute left-[-15px] top-[-15px] z-10 invisible opacity-0 transition-opacity group-hover/item:visible group-hover/item:opacity-100 group-focus-within/item:visible group-focus-within/item:opacity-100"
size="icon"
aria-label="Remove item"
onClick={deleteItem(itemIndex)}
>
<TrashIcon />
</Button>
</div>
</div>
))}

View File

@ -1,13 +1,19 @@
import { Input, type InputProps } from "@typebot.io/ui/components/Input";
import { cn } from "@typebot.io/ui/lib/cn";
import { forwardRef, type HTMLAttributes, useRef, useState } from "react";
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?: HTMLAttributes<HTMLSpanElement>;
common?: HTMLAttributes<HTMLSpanElement | HTMLInputElement>;
preview?: ButtonHTMLAttributes<HTMLButtonElement>;
common?: HTMLAttributes<HTMLButtonElement | HTMLInputElement>;
defaultEdit?: boolean;
value?: string;
defaultValue?: string;
@ -75,17 +81,12 @@ export const SingleLineEditable = forwardRef<
)}
/>
) : (
<span
<button
{...preview}
type="button"
onClick={(e) => {
preview?.onClick?.(e);
setIsEditing(true);
}}
onKeyDown={(event) => {
preview?.onKeyDown?.(event);
if (event.defaultPrevented) return;
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
if (e.defaultPrevented) return;
setIsEditing(true);
}}
className={cn(
@ -95,7 +96,7 @@ export const SingleLineEditable = forwardRef<
)}
>
{value ?? currentValue}
</span>
</button>
)}
{children}
</div>

View File

@ -132,6 +132,7 @@ const StackWithGhostableItems = ({
ghostItemHeight={gapPixel / childrenLength}
closeExpanded={onAbort}
>
{/* biome-ignore lint/a11y/noStaticElementInteractions: This hover surface only expands placeholder spacing; the interactive controls are the ghost buttons inside. */}
<div
style={
{

View File

@ -39,7 +39,6 @@ export const TableList = <T extends object>({
addIdsIfMissing(initialItems) ??
(hasDefaultItem ? ([defaultItem] as T[]) : []),
);
const [showDeleteIndex, setShowDeleteIndex] = useState<number | null>(null);
useEffect(() => {
if (items.length && initialItems && initialItems?.length === 0)
@ -78,14 +77,9 @@ export const TableList = <T extends object>({
onItemsChange([...newItems]);
};
const handleMouseEnter = (itemIndex: number) => () =>
setShowDeleteIndex(itemIndex);
const handleCellChange = (itemIndex: number) => (item: T) =>
updateItem(itemIndex, item);
const handleMouseLeave = () => setShowDeleteIndex(null);
return (
<div className="flex flex-col gap-0">
{items.map((item, itemIndex) => (
@ -95,32 +89,28 @@ export const TableList = <T extends object>({
)}
<div
className={cx(
"flex relative justify-center pb-4",
"group/item flex relative justify-center pb-4",
itemIndex !== 0 && ComponentBetweenItems ? "mt-4" : "mt-0",
)}
onMouseEnter={handleMouseEnter(itemIndex)}
onMouseLeave={handleMouseLeave}
>
{children({ item, onItemChange: handleCellChange(itemIndex) })}
{showDeleteIndex === itemIndex && (
<Button
size="icon"
aria-label="Remove cell"
onClick={deleteItem(itemIndex)}
variant="secondary"
className="shadow-md size-6 animate-in fade-in-0 absolute left-[-8px] top-[-8px]"
>
<TrashIcon />
</Button>
)}
{isOrdered && showDeleteIndex === itemIndex && (
<Button
size="icon"
aria-label="Remove cell"
onClick={deleteItem(itemIndex)}
variant="secondary"
className="shadow-md size-6 absolute left-[-8px] top-[-8px] invisible opacity-0 transition-opacity group-hover/item:visible group-hover/item:opacity-100 group-focus-within/item:visible group-focus-within/item:opacity-100"
>
<TrashIcon />
</Button>
{isOrdered && (
<>
<Button
size="icon"
aria-label={addLabel}
onClick={insertItemAt(itemIndex)}
variant="secondary"
className="shadow-md size-6 animate-in fade-in-0 slide-in-from-bottom-1 absolute top-[-10px]"
className="shadow-md size-6 absolute top-[-10px] invisible opacity-0 transition-opacity group-hover/item:visible group-hover/item:opacity-100 group-focus-within/item:visible group-focus-within/item:opacity-100"
>
<PlusSignIcon />
</Button>
@ -129,7 +119,7 @@ export const TableList = <T extends object>({
aria-label={addLabel}
onClick={insertItemAt(itemIndex + 1)}
variant="secondary"
className="shadow-md size-6 animate-in fade-in-0 slide-in-from-top-1 absolute bottom-2"
className="shadow-md size-6 absolute bottom-2 invisible opacity-0 transition-opacity group-hover/item:visible group-hover/item:opacity-100 group-focus-within/item:visible group-focus-within/item:opacity-100"
>
<PlusSignIcon />
</Button>

View File

@ -81,9 +81,6 @@ export const TagsInput = ({ items, placeholder, onValueChange }: Props) => {
return (
<div
className="flex flex-wrap gap-1 border py-1 px-2 rounded-md data-[focus=true]:outline-none data-[focus=true]:ring-orange-8 data-[focus=true]:ring-2 data-[focus=true]:border-transparent transition-[box-shadow,border-color]"
onClick={() => inputRef.current?.focus()}
onBlur={addItem}
onKeyDown={handleKeyDown}
data-focus={isFocused}
>
{items?.map((item, index) => (
@ -101,8 +98,12 @@ export const TagsInput = ({ items, placeholder, onValueChange }: Props) => {
value={inputValue}
onValueChange={handleInputChange}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
onBlur={() => {
setIsFocused(false);
addItem();
}}
onKeyDown={(e) => {
handleKeyDown(e);
if (e.key === "Enter") addItem();
}}
placeholder={items && items.length === 0 ? placeholder : undefined}

View File

@ -224,14 +224,12 @@ const PexelsVideo = ({ video, onClick }: PexelsVideoProps) => {
}, [isImageHovered, imageIndex, video_pictures]);
return (
<div
className="relative"
onMouseEnter={() => setIsImageHovered(true)}
onMouseLeave={() => setIsImageHovered(false)}
>
<div className="group relative">
<button
type="button"
className="size-full rounded-md cursor-pointer p-0 border-none bg-transparent"
onMouseEnter={() => setIsImageHovered(true)}
onMouseLeave={() => setIsImageHovered(false)}
onClick={onClick}
>
<img
@ -243,12 +241,7 @@ const PexelsVideo = ({ video, onClick }: PexelsVideoProps) => {
alt={`Pexels Video ${video.id}`}
/>
</button>
<div
className={cx(
"absolute px-2 rounded-md bottom-0 left-0 bg-black/50 opacity-0 transition-opacity",
isImageHovered ? "opacity-100" : "opacity-0",
)}
>
<div className="absolute px-2 rounded-md bottom-0 left-0 bg-black/50 opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100">
<TextLink className="text-xs text-white" isExternal href={url}>
{user.name}
</TextLink>

View File

@ -53,14 +53,14 @@ export const BlockCard = (
tooltip={t("blocks.inputs.fileUpload.blockCard.tooltip")}
>
<BlockIcon type={props.type} />
<div className="flex items-center gap-2">
<span className="flex items-center gap-2">
<BlockLabel type={props.type} />
{isFreePlan(workspace) && (
<Badge colorScheme="orange">
<SquareLock01Icon />
</Badge>
)}
</div>
</span>
</BlockCardLayout>
);
case LogicBlockType.SCRIPT:

View File

@ -32,7 +32,8 @@ export const BlockCardLayout = ({
<Tooltip.Trigger
render={
<div className="flex relative">
<div
<button
type="button"
className={cx(
"flex items-center gap-2 border dark:border-gray-3 rounded-lg flex-1 px-4 py-2 cursor-grab bg-gray-2 hover:shadow-md dark:hover:bg-gray-3 dark:hover:shadow-none transition-[box-shadow,background-color]",
isMouseDown ? "opacity-40 min-h-[42px]" : "opacity-100",
@ -40,7 +41,7 @@ export const BlockCardLayout = ({
onMouseDown={handleMouseDown}
>
{!isMouseDown ? children : null}
</div>
</button>
</div>
}
/>

View File

@ -7,12 +7,10 @@ export const BlockCardOverlay = ({
type,
className,
style,
onMouseUp,
}: {
type: BlockV6["type"];
className?: string;
style?: React.CSSProperties;
onMouseUp: () => void;
}) => {
return (
<div
@ -20,7 +18,6 @@ export const BlockCardOverlay = ({
"flex items-center gap-2 border rounded-lg w-[147px] px-4 py-2 shadow-xl cursor-grabbing transition-none pointer-events-none z-10 bg-gray-2",
className,
)}
onMouseUp={onMouseUp}
style={style}
>
<BlockIcon type={type} />

View File

@ -15,7 +15,7 @@ import { SquareLock01Icon } from "@typebot.io/ui/icons/SquareLock01Icon";
import { SquareUnlock01Icon } from "@typebot.io/ui/icons/SquareUnlock01Icon";
import { cx } from "@typebot.io/ui/lib/cva";
import type React from "react";
import { useState } from "react";
import { useRef, useState } from "react";
import { useDebouncedCallback } from "use-debounce";
import { Portal } from "@/components/Portal";
import { useBlockDnd } from "@/features/graph/providers/GraphDndProvider";
@ -59,22 +59,36 @@ export const BlocksSideBar = () => {
localStorage.getItem(leftSidebarLockedStorageKey) !== "false",
);
const [searchInput, setSearchInput] = useState("");
const sidebarRef = useRef<HTMLDivElement>(null);
const dockBarRef = useRef<HTMLButtonElement>(null);
const closeSideBar = useDebouncedCallback(() => setIsExtended(false), 200);
const handleMouseMove = (event: MouseEvent) => {
if (!draggedBlockType && !draggedEventType) return;
const { clientX, clientY } = event;
setPosition({
...position,
x: clientX - relativeCoordinates.x,
y: clientY - relativeCoordinates.y,
});
if (draggedBlockType || draggedEventType) {
setPosition({
x: clientX - relativeCoordinates.x,
y: clientY - relativeCoordinates.y,
});
}
if (isLocked) return;
if (isMouseInElement(sidebarRef.current, clientX, clientY)) {
closeSideBar.flush();
return;
}
if (isMouseInElement(dockBarRef.current, clientX, clientY)) {
closeSideBar.flush();
setIsExtended(true);
return;
}
if (clientX < 100) return;
closeSideBar();
};
useEventListener("mousemove", handleMouseMove);
const initBlockDragging = (e: React.MouseEvent, type: BlockV6["type"]) => {
const element = e.currentTarget as HTMLDivElement;
const element = e.currentTarget as HTMLElement;
const rect = element.getBoundingClientRect();
setPosition({ x: rect.left, y: rect.top });
const x = e.clientX - rect.left;
@ -87,7 +101,7 @@ export const BlocksSideBar = () => {
e: React.MouseEvent,
type: TDraggableEvent["type"],
) => {
const element = e.currentTarget as HTMLDivElement;
const element = e.currentTarget as HTMLElement;
const rect = element.getBoundingClientRect();
setPosition({ x: rect.left, y: rect.top });
const x = e.clientX - rect.left;
@ -116,16 +130,6 @@ export const BlocksSideBar = () => {
setIsLocked(!isLocked);
};
const handleDockBarEnter = () => {
closeSideBar.flush();
setIsExtended(true);
};
const handleMouseLeave = (e: React.MouseEvent) => {
if (isLocked || e.clientX < 100) return;
closeSideBar();
};
const handleSearchInputChange = (event: {
target: { value: React.SetStateAction<string> };
}) => {
@ -187,11 +191,11 @@ export const BlocksSideBar = () => {
return (
<div
ref={sidebarRef}
className={cx(
"flex w-[360px] absolute pl-4 py-4 left-0 transition-transform duration-150 ease-out h-[calc(100vh-var(--header-height))]",
isExtended ? "translate-x-0" : "translate-x-[-350px]",
)}
onMouseLeave={handleMouseLeave}
>
<div className="flex flex-col w-full rounded-lg border pt-4 pb-10 px-4 gap-6 overflow-y-auto bg-gray-1 select-none">
<div className="flex justify-between w-full items-center gap-3">
@ -303,7 +307,6 @@ export const BlocksSideBar = () => {
<Portal>
<BlockCardOverlay
type={draggedBlockType}
onMouseUp={handleMouseUp}
className="fixed top-0 left-0"
style={{
transform: `translate(${position.x}px, ${position.y}px) rotate(-2deg)`,
@ -315,7 +318,6 @@ export const BlocksSideBar = () => {
<Portal>
<EventCardOverlay
type={draggedEventType}
onMouseUp={handleMouseUp}
className="fixed top-0 left-0"
style={{
transform: `translate(${position.x}px, ${position.y}px) rotate(-2deg)`,
@ -325,13 +327,34 @@ export const BlocksSideBar = () => {
)}
</div>
{!isLocked && (
<div
<button
ref={dockBarRef}
type="button"
aria-label="Open blocks sidebar"
className="flex animate-in fade-in-0 absolute h-full w-[450px] justify-end pr-10 items-center -right-[70px] top-0 -z-10"
onMouseEnter={handleDockBarEnter}
onFocus={() => {
closeSideBar.flush();
setIsExtended(true);
}}
>
<div className="flex w-[5px] h-[20px] rounded-md bg-gray-7" />
</div>
<span className="flex w-[5px] h-[20px] rounded-md bg-gray-7" />
</button>
)}
</div>
);
};
const isMouseInElement = (
element: HTMLElement | null,
clientX: number,
clientY: number,
) => {
if (!element) return false;
const rect = element.getBoundingClientRect();
return (
clientX >= rect.left &&
clientX <= rect.right &&
clientY >= rect.top &&
clientY <= rect.bottom
);
};

View File

@ -36,7 +36,9 @@ export const EventCardLayout = ({
<Tooltip.Trigger
render={
<div className="flex relative">
<div
<button
type="button"
disabled={isDisabled}
className={cx(
"flex items-center gap-2 border rounded-lg flex-1 px-4 py-2 bg-gray-1 transition-[box-shadow,background-color]",
isMouseDown ? "min-h-[42px]" : undefined,
@ -48,7 +50,7 @@ export const EventCardLayout = ({
onMouseDown={handleMouseDown}
>
{!isMouseDown ? children : null}
</div>
</button>
</div>
}
/>

View File

@ -7,12 +7,10 @@ export const EventCardOverlay = ({
type,
className,
style,
onMouseUp,
}: {
type: TDraggableEvent["type"];
className?: string;
style?: React.CSSProperties;
onMouseUp: () => void;
}) => {
return (
<div
@ -20,7 +18,6 @@ export const EventCardOverlay = ({
"flex items-center gap-2 border rounded-lg w-[147px] px-4 py-2 shadow-xl cursor-grabbing transition-none pointer-events-none z-10 border-gray-9 bg-gray-1",
className,
)}
onMouseUp={onMouseUp}
style={style}
>
<EventIcon type={type} />

View File

@ -201,7 +201,6 @@ export const FolderContent = ({ folder }: Props) => {
<Portal>
<TypebotButtonOverlay
typebot={draggedTypebot}
onMouseUp={handleMouseUp}
className="fixed top-0 left-0 origin-[0_0_0]"
style={{
transform: `translate(${draggablePosition.x}px, ${draggablePosition.y}px) rotate(-2deg)`,

View File

@ -6,23 +6,16 @@ import type { TypebotInDashboard } from "@/features/dashboard/types";
type Props = {
typebot: TypebotInDashboard;
className?: string;
onMouseUp?: () => Promise<void>;
style?: React.CSSProperties;
};
export const TypebotButtonOverlay = ({
typebot,
className,
onMouseUp,
style,
}: Props) => {
export const TypebotButtonOverlay = ({ typebot, className, style }: Props) => {
return (
<div
className={cn(
"flex flex-col justify-center w-[225px] h-[270px] border rounded-md shadow-md whitespace-normal transition-none pointer-events-none opacity-70 bg-gray-1",
className,
)}
onMouseUp={onMouseUp}
style={style}
>
<div className="flex flex-col items-center gap-4">

View File

@ -155,48 +155,33 @@ export const DropOffEdge = ({
<Tooltip.Root>
<Tooltip.Trigger
render={
<div
className={cx(
"flex flex-col items-center rounded-md p-2 justify-center w-full h-full gap-0.5 bg-red-9 text-white",
isWorkspaceProPlan ? "cursor-auto" : "cursor-pointer",
)}
data-testid={`dropoff-edge-${blockId}`}
role={isWorkspaceProPlan ? undefined : "button"}
tabIndex={isWorkspaceProPlan ? undefined : 0}
onClick={isWorkspaceProPlan ? undefined : onUnlockProPlanClick}
onKeyDown={(event) => {
if (
isWorkspaceProPlan ||
!onUnlockProPlanClick ||
(event.key !== "Enter" && event.key !== " ")
)
return;
event.preventDefault();
onUnlockProPlanClick();
}}
>
<p
className={cx(
"text-sm",
isWorkspaceProPlan ? undefined : "blur-[2px]",
)}
isWorkspaceProPlan ? (
<div
className="flex flex-col items-center rounded-md p-2 justify-center w-full h-full gap-0.5 bg-red-9 text-white"
data-testid={`dropoff-edge-${blockId}`}
>
{isWorkspaceProPlan ? (
dropOffRate
) : (
<span className="blur-[2px]">X</span>
)}
%
</p>
<Badge colorScheme="red">
{isWorkspaceProPlan ? (
totalDroppedUser
) : (
<span className="mr-1 blur-[3px]">NN</span>
)}{" "}
user{(totalDroppedUser ?? 2) > 1 ? "s" : ""}
</Badge>
</div>
<span className="text-sm">{dropOffRate}%</span>
<Badge colorScheme="red">
{totalDroppedUser} user
{(totalDroppedUser ?? 2) > 1 ? "s" : ""}
</Badge>
</div>
) : (
<button
type="button"
className="flex flex-col items-center rounded-md p-2 justify-center w-full h-full gap-0.5 bg-red-9 text-white cursor-pointer"
data-testid={`dropoff-edge-${blockId}`}
onClick={onUnlockProPlanClick}
>
<span className={cx("text-sm", "blur-[2px]")}>
<span className="blur-[2px]">X</span>%
</span>
<Badge colorScheme="red">
<span className="mr-1 blur-[3px]">NN</span> user
{(totalDroppedUser ?? 2) > 1 ? "s" : ""}
</Badge>
</button>
)
}
/>
<Tooltip.Popup>

View File

@ -108,6 +108,7 @@ export const Edge = ({ edge, fromElementId }: Props) => {
<ContextMenu.Trigger
render={(props) => (
<>
{/* biome-ignore lint/a11y/noStaticElementInteractions: SVG paths are graph hit areas, not HTML controls, and React Flow relies on this shape for edge selection. */}
<path
{...props}
data-testid="clickable-edge"

View File

@ -43,54 +43,61 @@ export const PlaceholderNode = forwardRef<HTMLDivElement, Props>(
enabled: isDefined(onClick),
});
return (
<div
const placeholderContent = (
<span
style={
{
"--py":
"--h":
isExpanded || isHoverExpanded
? `${expandedPaddingPixel}px`
: `${initialPaddingPixel}px`,
? `${expandedHeightPixels}px`
: `${initialHeightPixels}px`,
} as React.CSSProperties
}
className={cn(
"flex font-semibold justify-center items-center relative py-(--py) text-sm",
className,
className={cx(
"flex w-full rounded-lg justify-center items-center bg-gray-3 h-(--h) transition-[opacity,height]",
isVisible || isHovered ? "opacity-100" : "opacity-0",
)}
ref={ref}
onMouseEnter={onHover}
onMouseLeave={onLeave}
onMouseUpCapture={onAbort}
{...(onClick
? {
role: "button",
tabIndex: 0,
onClick,
onKeyDown: (event: React.KeyboardEvent) => {
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
onClick();
},
}
: {})}
>
{onClick && <span className="absolute left-0 z-1 w-full" />}
<div
style={
{
"--h":
isExpanded || isHoverExpanded
? `${expandedHeightPixels}px`
: `${initialHeightPixels}px`,
} as React.CSSProperties
}
className={cx(
"flex w-full rounded-lg justify-center items-center bg-gray-3 h-(--h) transition-[opacity,height]",
isVisible || isHovered ? "opacity-100" : "opacity-0",
)}
>
{isHovered && isHoverExpanded ? children : null}
</div>
{isHovered && isHoverExpanded ? children : null}
</span>
);
return (
<div className={cn("relative", className)} ref={ref}>
{onClick ? (
<button
type="button"
style={
{
"--py":
isExpanded || isHoverExpanded
? `${expandedPaddingPixel}px`
: `${initialPaddingPixel}px`,
} as React.CSSProperties
}
className="flex w-full font-semibold justify-center items-center py-(--py) text-sm"
onMouseEnter={onHover}
onMouseLeave={onLeave}
onMouseUpCapture={onAbort}
onClick={onClick}
>
{placeholderContent}
</button>
) : (
<div
style={
{
"--py":
isExpanded || isHoverExpanded
? `${expandedPaddingPixel}px`
: `${initialPaddingPixel}px`,
} as React.CSSProperties
}
className="flex w-full font-semibold justify-center items-center py-(--py) text-sm"
>
{placeholderContent}
</div>
)}
</div>
);
},

View File

@ -227,6 +227,7 @@ export const BlockNode = ({
render={(props) => (
<ContextMenu.Root onOpenChange={setIsContextMenuOpened}>
<ContextMenu.Trigger>
{/* biome-ignore lint/a11y/noStaticElementInteractions: This node container is a React Flow drag surface with nested controls, not a standalone HTML action. */}
<div
className="flex relative w-full prevent-group-drag"
{...props}
@ -286,6 +287,8 @@ export const BlockNode = ({
)}
/>
{/* Prevent triggering parent group context menu */}
{/* biome-ignore lint/a11y/noNoninteractiveElementInteractions: This wrapper only stops context-menu propagation for nested popovers. */}
{/* biome-ignore lint/a11y/noStaticElementInteractions: This wrapper only stops context-menu propagation for nested popovers. */}
<div onContextMenu={(e) => e.stopPropagation()}>
{hasSettingsPopover(block) && (
<SettingsPopoverContent

View File

@ -154,6 +154,7 @@ export const GroupNode = ({ group, groupIndex }: Props) => {
disabled={isReadOnly}
>
<ContextMenu.Trigger>
{/* biome-ignore lint/a11y/noStaticElementInteractions: This group container is a draggable graph surface with nested controls, not a standalone HTML action. */}
<div
style={
{

View File

@ -80,6 +80,7 @@ export const ItemNode = ({
return (
<ContextMenu.Root onOpenChange={setIsContextMenuOpened}>
<ContextMenu.Trigger>
{/* biome-ignore lint/a11y/noStaticElementInteractions: This item container is a draggable graph surface with nested controls, not a standalone HTML action. */}
<div
className="flex flex-col gap-2 relative w-full"
data-testid="item"

View File

@ -140,6 +140,7 @@ export const ItemNodesList = ({
return (
// biome-ignore lint/a11y/useKeyWithClickEvents: This wrapper only prevents click propagation.
// biome-ignore lint/a11y/noStaticElementInteractions: This wrapper only prevents click propagation inside the graph surface.
<div
className="flex flex-col gap-0 max-w-full flex-1"
onClick={stopPropagating}

View File

@ -27,7 +27,6 @@ export const PreviewDrawer = () => {
const { t } = useTranslate();
const { setPreviewingBlock } = useGraph();
const [width, setWidth] = useState(500);
const [isResizeHandleVisible, setIsResizeHandleVisible] = useState(false);
const [selectedRuntime, setSelectedRuntime] = useState<
(typeof runtimes)[number]
>(getDefaultRuntime(typebot?.id));
@ -61,19 +60,13 @@ export const PreviewDrawer = () => {
return (
<div
className="flex absolute border-l shadow-md p-6 right-0 top-0 h-full bg-gray-1 rounded-l-lg z-10"
onMouseOver={() => setIsResizeHandleVisible(true)}
onMouseLeave={() => setIsResizeHandleVisible(false)}
onFocus={() => setIsResizeHandleVisible(true)}
onBlur={() => setIsResizeHandleVisible(false)}
className="group/drawer flex absolute border-l shadow-md p-6 right-0 top-0 h-full bg-gray-1 rounded-l-lg z-10"
style={{ width: `${width}px` }}
>
{isResizeHandleVisible && (
<ResizeHandle
{...useResizeHandleDrag()}
className="animate-in fade-in-0 absolute left-[-7.5px] top-1/2 -translate-y-1/2"
/>
)}
<ResizeHandle
{...useResizeHandleDrag()}
className="absolute left-[-7.5px] top-1/2 -translate-y-1/2 opacity-0 pointer-events-none transition-opacity group-hover/drawer:opacity-100 group-hover/drawer:pointer-events-auto group-focus-within/drawer:opacity-100 group-focus-within/drawer:pointer-events-auto"
/>
<div className="flex flex-col items-center w-full gap-4">
<div className="flex items-center gap-2 justify-between w-full">
<div className="flex items-center gap-2">

View File

@ -33,7 +33,6 @@ export const VariablesDrawer = ({ onClose }: Props) => {
const { typebot, createVariable, updateVariable, deleteVariable } =
useTypebot();
const [width, setWidth] = useState(500);
const [isResizeHandleVisible, setIsResizeHandleVisible] = useState(false);
const [searchValue, setSearchValue] = useState("");
const filteredVariables = typebot?.variables.filter((v) =>
isNotEmpty(searchValue)
@ -71,20 +70,14 @@ export const VariablesDrawer = ({ onClose }: Props) => {
return (
<div
className="flex absolute border-l shadow-md p-6 right-0 top-0 h-full bg-gray-1 rounded-l-lg"
onMouseOver={() => setIsResizeHandleVisible(true)}
onFocus={() => setIsResizeHandleVisible(true)}
onBlur={() => setIsResizeHandleVisible(false)}
onMouseLeave={() => setIsResizeHandleVisible(false)}
className="group/drawer flex absolute border-l shadow-md p-6 right-0 top-0 h-full bg-gray-1 rounded-l-lg"
style={{ width: `${width}px` }}
>
{isResizeHandleVisible && (
<ResizeHandle
{...useResizeHandleDrag()}
className="animate-in fade-in-0 absolute left-[-7.5px]"
style={{ top: `calc(50% - ${headerHeight}px)` }}
/>
)}
<ResizeHandle
{...useResizeHandleDrag()}
className="absolute left-[-7.5px] opacity-0 pointer-events-none transition-opacity group-hover/drawer:opacity-100 group-hover/drawer:pointer-events-auto group-focus-within/drawer:opacity-100 group-focus-within/drawer:pointer-events-auto"
style={{ top: `calc(50% - ${headerHeight}px)` }}
/>
<div className="flex flex-col w-full gap-4">
<Button
className="absolute right-2 top-2"

View File

@ -5,6 +5,6 @@ export const setLocaleInCookies = async (locale: string) => {
name: "NEXT_LOCALE",
value: encodeURIComponent(locale),
path: "/",
expires: new Date(Date.now() + 31_536_000_000),
expires: Date.now() + 31_536_000_000,
});
};

View File

@ -3,7 +3,7 @@ import { ArrowDown01Icon } from "@typebot.io/ui/icons/ArrowDown01Icon";
import { ArrowUp01Icon } from "@typebot.io/ui/icons/ArrowUp01Icon";
import { cn } from "@typebot.io/ui/lib/cn";
import { motion } from "motion/react";
import { useState } from "react";
import { useId, useState } from "react";
import threeDButton from "./assets/3d-button.png";
const data = [
@ -81,19 +81,18 @@ const Principle = ({
isLastItem: boolean;
onClick: () => void;
}) => {
const contentId = useId();
return (
<details
className="rounded-xl md:rounded-none md:px-0 bg-white border md:border-0 border-border cursor-pointer"
open={isOpened}
>
<summary
className="px-4 py-4 md:py-2 font-display font-medium text-2xl flex flex-col gap-3 list-none"
onClick={(e) => {
e.preventDefault();
onClick();
}}
<div className="rounded-xl md:rounded-none md:px-0 bg-white border md:border-0 border-border">
<button
type="button"
className="w-full px-4 py-4 font-display font-medium text-2xl flex flex-col items-stretch gap-3 text-left cursor-pointer"
aria-expanded={isOpened}
aria-controls={contentId}
onClick={onClick}
>
<div className="flex justify-between">
<span className="flex justify-between">
{title}
<span
className={cn(
@ -107,11 +106,12 @@ const Principle = ({
<ArrowDown01Icon />
)}
</span>
</div>
{isLastItem ? null : <hr className="hidden md:block" />}
</summary>
</span>
</button>
{isLastItem ? null : <hr className="hidden md:block mx-4" />}
<motion.div
id={contentId}
className="overflow-hidden"
initial={{ height: 0, opacity: 0 }}
animate={{
height: isOpened ? "auto" : 0,
@ -120,8 +120,8 @@ const Principle = ({
transition={{ duration: 0.4, type: "spring", bounce: 0.15 }}
>
<hr className="mb-4 md:hidden mx-4 border-border" />
<p className="pb-4 mx-4">{content}</p>
<p className="py-4 mx-4">{content}</p>
</motion.div>
</details>
</div>
);
};

View File

@ -11,6 +11,6 @@ export const setCookie = async (consent: "declined" | "accepted") => {
value: encodeURIComponent(JSON.stringify({ consent })),
domain: DEFAULT_COOKIE_DOMAIN,
path: "/",
expires: new Date(Date.now() + COOKIE_EXPIRATION * 1000),
expires: Date.now() + COOKIE_EXPIRATION * 1000,
});
};

View File

@ -31,10 +31,6 @@
"enabled": true,
"rules": {
"recommended": true,
"security": {},
"a11y": {
"noStaticElementInteractions": "off"
},
"performance": {
"noImgElement": "off"
},

View File

@ -638,7 +638,7 @@
},
"packages/embeds/js": {
"name": "@typebot.io/js",
"version": "0.9.20",
"version": "0.9.21",
"devDependencies": {
"@ai-sdk/ui-utils": "^1.2.11",
"@ark-ui/solid": "^5.19.0",
@ -675,7 +675,7 @@
},
"packages/embeds/react": {
"name": "@typebot.io/react",
"version": "0.9.20",
"version": "0.9.21",
"dependencies": {
"@typebot.io/js": "workspace:*",
"react": "^19.2.3",

View File

@ -1,6 +1,6 @@
{
"name": "@typebot.io/js",
"version": "0.9.20",
"version": "0.9.21",
"description": "Javascript library to display typebots on your website",
"license": "FSL-1.1-ALv2",
"type": "module",

View File

@ -28,8 +28,9 @@ export const EmailInput = (props: Props) => {
else inputRef?.focus();
};
const submitWhenEnter = (e: KeyboardEvent) => {
if (e.key === "Enter") submit();
const handleSubmit = (event: Event) => {
event.preventDefault();
submit();
};
onMount(() => {
@ -50,9 +51,9 @@ export const EmailInput = (props: Props) => {
};
return (
<div
<form
class="typebot-input-form flex w-full gap-2 items-end max-w-[350px]"
onKeyDown={submitWhenEnter}
onSubmit={handleSubmit}
>
<div class={"flex typebot-input w-full"}>
<ShortTextInput
@ -67,9 +68,9 @@ export const EmailInput = (props: Props) => {
autocomplete="email"
/>
</div>
<SendButton type="button" class="h-[56px]" on:click={submit}>
<SendButton type="submit" class="h-14">
{props.block.options?.labels?.button}
</SendButton>
</div>
</form>
);
};

View File

@ -49,8 +49,9 @@ export const NumberInput = (props: NumberInputProps) => {
} else numberInput().focus();
};
const submitWhenEnter = (e: KeyboardEvent) => {
if (e.key === "Enter") submit();
const handleSubmit = (event: Event) => {
event.preventDefault();
submit();
};
onMount(() => {
@ -71,9 +72,9 @@ export const NumberInput = (props: NumberInputProps) => {
};
return (
<div
<form
class="typebot-input-form flex w-full gap-2 items-end max-w-[350px]"
onKeyDown={submitWhenEnter}
onSubmit={handleSubmit}
>
<ArkNumberInput.RootProvider
value={numberInput}
@ -89,18 +90,24 @@ export const NumberInput = (props: NumberInputProps) => {
}
/>
<ArkNumberInput.Control class="flex flex-col rounded-r-md overflow-hidden divide-y h-[56px]">
<ArkNumberInput.IncrementTrigger class="flex items-center justify-center h-7 w-8 border-input-border border-l">
<ArkNumberInput.IncrementTrigger
type="button"
class="flex items-center justify-center h-7 w-8 border-input-border border-l"
>
<ChevronUpIcon class="size-4" />
</ArkNumberInput.IncrementTrigger>
<ArkNumberInput.DecrementTrigger class="flex items-center justify-center h-7 w-8 border-input-border border-l">
<ArkNumberInput.DecrementTrigger
type="button"
class="flex items-center justify-center h-7 w-8 border-input-border border-l"
>
<ChevronDownIcon class="size-4" />
</ArkNumberInput.DecrementTrigger>
</ArkNumberInput.Control>
</ArkNumberInput.RootProvider>
<SendButton type="button" class="h-[56px]" on:click={submit}>
<SendButton type="submit" class="h-14">
{props.block.options?.labels?.button ?? defaultNumberInputButtonLabel}
</SendButton>
</div>
</form>
);
};

View File

@ -68,8 +68,9 @@ export const PhoneInput = (props: PhoneInputProps) => {
} else inputRef?.focus();
};
const submitWhenEnter = (e: KeyboardEvent) => {
if (e.key === "Enter") submit();
const handleSubmit = (event: Event) => {
event.preventDefault();
submit();
};
const selectNewCountryCode = (
@ -111,9 +112,9 @@ export const PhoneInput = (props: PhoneInputProps) => {
};
return (
<div
<form
class="typebot-input-form flex w-full gap-2 items-end max-w-[350px]"
onKeyDown={submitWhenEnter}
onSubmit={handleSubmit}
>
<div class={"flex typebot-input w-full"}>
<div class="relative typebot-country-select flex justify-center items-center">
@ -158,9 +159,9 @@ export const PhoneInput = (props: PhoneInputProps) => {
autofocus={!guessDeviceIsMobile()}
/>
</div>
<SendButton type="button" class="h-[56px]" on:click={submit}>
<SendButton type="submit" class="h-14">
{props.labels?.button}
</SendButton>
</div>
</form>
);
};

View File

@ -102,9 +102,9 @@ export const TextInput = (props: Props) => {
} else inputRef?.focus();
};
const submitWhenEnter = (e: KeyboardEvent) => {
if (props.block.options?.isLong) return;
if (e.key === "Enter") submit();
const handleSubmit = (event: Event) => {
event.preventDefault();
submit();
};
const submitIfCtrlEnter = (e: KeyboardEvent) => {
@ -261,14 +261,14 @@ export const TextInput = (props: Props) => {
};
return (
<div
<form
class={cx(
"typebot-input-form flex w-full gap-2 items-end",
props.block.options?.isLong && recordingStatus() !== "started"
? "max-w-full"
: "max-w-[350px]",
)}
onKeyDown={submitWhenEnter}
onSubmit={handleSubmit}
onDrop={handleDropFile}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
@ -365,7 +365,8 @@ export const TextInput = (props: Props) => {
}
>
<Button
class="h-[56px] flex items-center"
type="button"
class="h-14 flex items-center"
on:click={recordVoice}
aria-label="Record voice"
>
@ -374,15 +375,14 @@ export const TextInput = (props: Props) => {
</Match>
<Match when={true}>
<SendButton
type="button"
on:click={submit}
type="submit"
isDisabled={Boolean(uploadProgress())}
class="h-[56px]"
class="h-14"
>
{props.block.options?.labels?.button}
</SendButton>
</Match>
</Switch>
</div>
</form>
);
};

View File

@ -26,8 +26,9 @@ export const TimeForm = (props: Props) => {
else inputRef?.focus();
};
const submitWhenEnter = (e: KeyboardEvent) => {
if (e.key === "Enter") submit();
const handleSubmit = (event: Event) => {
event.preventDefault();
submit();
};
onMount(() => {
@ -48,9 +49,9 @@ export const TimeForm = (props: Props) => {
};
return (
<div
<form
class="typebot-input-form flex w-full gap-2 items-end max-w-[350px]"
onKeyDown={submitWhenEnter}
onSubmit={handleSubmit}
>
<div class={"flex typebot-input w-full"}>
<input
@ -67,9 +68,9 @@ export const TimeForm = (props: Props) => {
data-testid="time"
/>
</div>
<SendButton type="button" class="h-[56px]" on:click={submit}>
<SendButton type="submit" class="h-14">
{props.block?.labels?.button}
</SendButton>
</div>
</form>
);
};

View File

@ -32,8 +32,9 @@ export const UrlInput = (props: Props) => {
else inputRef?.focus();
};
const submitWhenEnter = (e: KeyboardEvent) => {
if (e.key === "Enter") submit();
const handleSubmit = (event: Event) => {
event.preventDefault();
submit();
};
onMount(() => {
@ -57,9 +58,9 @@ export const UrlInput = (props: Props) => {
};
return (
<div
<form
class="typebot-input-form flex w-full gap-2 items-end max-w-[350px]"
onKeyDown={submitWhenEnter}
onSubmit={handleSubmit}
>
<div class={"flex typebot-input w-full"}>
<ShortTextInput
@ -74,9 +75,9 @@ export const UrlInput = (props: Props) => {
autocomplete="url"
/>
</div>
<SendButton type="button" class="h-[56px]" on:click={submit}>
<SendButton type="submit" class="h-14">
{props.block.options?.labels?.button}
</SendButton>
</div>
</form>
);
};

View File

@ -1,6 +1,6 @@
{
"name": "@typebot.io/react",
"version": "0.9.20",
"version": "0.9.21",
"description": "Convenient library to display typebots on your React app",
"license": "FSL-1.1-ALv2",
"type": "module",

View File

@ -15,13 +15,13 @@
"migrateSubscriptionsToUsageBased": "tsx src/migrateSubscriptionsToUsageBased.ts",
"insertUsersInBrevoList": "tsx src/insertUsersInBrevoList.ts",
"getUsage": "tsx src/getUsage.ts",
"suspendWorkspace": "tsx src/suspendWorkspace.ts",
"suspendWorkspace": "SKIP_ENV_CHECK=true dotenv -e ./.env.production -- tsx src/suspendWorkspace.ts",
"destroyUser": "SKIP_ENV_CHECK=true dotenv -e ./.env.production -- tsx src/destroyUser.ts",
"updateTypebot": "SKIP_ENV_CHECK=true dotenv -e ./.env.production -- tsx src/updateTypebot.ts",
"updateWorkspace": "tsx src/updateWorkspace.ts",
"updateWorkspace": "SKIP_ENV_CHECK=true dotenv -e ./.env.production -- tsx src/updateWorkspace.ts",
"inspectTypebot": "SKIP_ENV_CHECK=true dotenv -e ./.env.production -- tsx src/inspectTypebot.ts",
"inspectPublishedTypebot": "tsx src/inspectPublishedTypebot.ts",
"inspectWorkspace": "tsx src/inspectWorkspace.ts",
"inspectWorkspace": "SKIP_ENV_CHECK=true dotenv -e ./.env.production -- tsx src/inspectWorkspace.ts",
"getCoupon": "tsx src/getCoupon.ts",
"redeemCoupon": "tsx src/redeemCoupon.ts",
"exportResults": "SKIP_ENV_CHECK=true dotenv -e ./.env.production -- tsx src/exportResults.ts",

View File

@ -143,21 +143,7 @@ Workspaces cibles:
Pourquoi: demande souvent un vrai choix de markup ou de composition de composant, surtout quand il y a des boutons imbriques.
### PR 3C - politique image par workspace
Isoler la regle image dans une PR dediee:
- `performance/noImgElement`
Workspaces cibles:
- `apps/landing-page`
- autres apps Next si necessaire
- `packages/embeds/js` a traiter a part ou a exclure selon la politique retenue
Pourquoi: la regle pousse naturellement vers `next/image`, mais ce n'est pas adapte tel quel a tous les workspaces du monorepo.
### PR 3D - nettoyage boucles / callbacks / mutation de parametres
### PR 3C - nettoyage boucles / callbacks / mutation de parametres
Regrouper les refactors surtout mecaniques et peu lies a React:
@ -172,7 +158,7 @@ Workspaces cibles:
Pourquoi: diff assez reviewable si isole, risque produit faible a moyen, et bon rendement sur du code de support.
### PR 3E - durcissement de typage
### PR 3D - durcissement de typage
Traiter ensuite les raccourcis de typage les plus bruyants:
@ -186,7 +172,7 @@ Workspaces cibles:
Pourquoi: risque plus eleve sur les contrats de types partages; mieux vaut ne pas melanger ca avec les changements UI ou hooks.
### PR 3F - correction des hooks React
### PR 3E - correction des hooks React
Finir par les regles les plus semantiques cote React: