fix: React Console Warnings and Accessibility Issues in Langflow Frontend (#6950)

* fix errors on console jsx

*  (NodeOutputfield/index.tsx): Refactor InspectButton component to use forwardRef for better performance and maintainability
♻️ (NodeOutputfield/index.tsx): Refactor AlertDropdown component to use forwardRef for better performance and maintainability

* 📝 (dialog.tsx): Add VisuallyHidden component for accessibility and improve semantics in DialogContent component
🔧 (dialog.tsx): Update DialogContent component to conditionally include VisuallyHidden component for DialogTitle
🔧 (dialog.tsx): Export VisuallyHidden component from dialog.tsx
♻️ (textAnimation.tsx): Refactor TextEffectPerChar component to use TextEffect component with per="char" and as="span" properties for consistency

*  (CustomEdges/index.tsx): Destructure props to extract specific properties and pass the rest as domSafeProps for cleaner code
♻️ (singleAlertComponent/index.tsx): Remove unnecessary aria-hidden attribute from IconComponent to improve accessibility and reduce redundancy

*  (index.tsx): introduce ShortUniqueId library to generate unique keys for alert items in the dropdown list

*  (frontend): add support for displaying a list of items in the NoticeAlert component
📝 (frontend): update NoticeAlertType interface to include a list property for displaying multiple items in the alert

*  (styleUtils.ts): introduce new icon PencilRuler to the list of nodeIconsLucide for use in the frontend styling utilities.
This commit is contained in:
Cristhian Zanforlin Lousa 2025-03-10 09:44:44 -03:00 committed by GitHub
commit 08f886f507
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 243 additions and 197 deletions

View file

@ -56,11 +56,26 @@ export function DefaultEdge({
targetY: targetYNew,
});
const {
animated,
selectable,
deletable,
sourcePosition,
targetPosition,
pathOptions,
selected,
...domSafeProps
} = props;
return (
<BaseEdge
path={targetHandleObject.output_types ? edgePathLoop : edgePath}
strokeDasharray={targetHandleObject.output_types ? "5 5" : "0"}
{...props}
{...domSafeProps}
data-animated={animated ? "true" : "false"}
data-selectable={selectable ? "true" : "false"}
data-deletable={deletable ? "true" : "false"}
data-selected={selected ? "true" : "false"}
/>
);
}

View file

@ -3,7 +3,14 @@ import { ICON_STROKE_WIDTH } from "@/constants/constants";
import { targetHandleType } from "@/types/flow";
import { useUpdateNodeInternals } from "@xyflow/react";
import { cloneDeep } from "lodash";
import { memo, useCallback, useEffect, useMemo, useRef } from "react";
import {
forwardRef,
memo,
useCallback,
useEffect,
useMemo,
useRef,
} from "react";
import ForwardedIconComponent, {
default as IconComponent,
} from "../../../../components/common/genericIconComponent";
@ -96,49 +103,56 @@ const HideShowButton = memo(
);
const InspectButton = memo(
({
disabled,
displayOutputPreview,
unknownOutput,
errorOutput,
isToolMode,
title,
onClick,
id,
}: {
disabled: boolean | undefined;
displayOutputPreview: boolean;
unknownOutput: boolean | undefined;
errorOutput: boolean;
isToolMode: boolean;
title: string;
onClick: () => void;
id: string;
}) => (
<Button
disabled={disabled}
data-testid={`output-inspection-${title.toLowerCase()}-${id.toLowerCase()}`}
unstyled
onClick={onClick}
>
<IconComponent
name="TextSearchIcon"
strokeWidth={ICON_STROKE_WIDTH}
className={cn(
"icon-size",
isToolMode
? displayOutputPreview && !unknownOutput
? "text-background hover:text-secondary-hover"
: "cursor-not-allowed text-placeholder-foreground opacity-80"
: displayOutputPreview && !unknownOutput
? "text-foreground hover:text-primary-hover"
: "cursor-not-allowed text-placeholder-foreground opacity-60",
errorOutput ? "text-destructive" : "",
)}
/>
</Button>
forwardRef(
(
{
disabled,
displayOutputPreview,
unknownOutput,
errorOutput,
isToolMode,
title,
onClick,
id,
}: {
disabled: boolean | undefined;
displayOutputPreview: boolean;
unknownOutput: boolean | undefined;
errorOutput: boolean;
isToolMode: boolean;
title: string;
onClick: () => void;
id: string;
},
ref: React.ForwardedRef<HTMLButtonElement>,
) => (
<Button
ref={ref}
disabled={disabled}
data-testid={`output-inspection-${title.toLowerCase()}-${id.toLowerCase()}`}
unstyled
onClick={onClick}
>
<IconComponent
name="TextSearchIcon"
strokeWidth={ICON_STROKE_WIDTH}
className={cn(
"icon-size",
isToolMode
? displayOutputPreview && !unknownOutput
? "text-background hover:text-secondary-hover"
: "cursor-not-allowed text-placeholder-foreground opacity-80"
: displayOutputPreview && !unknownOutput
? "text-foreground hover:text-primary-hover"
: "cursor-not-allowed text-placeholder-foreground opacity-60",
errorOutput ? "text-destructive" : "",
)}
/>
</Button>
),
),
);
InspectButton.displayName = "InspectButton";
const MemoizedOutputComponent = memo(OutputComponent);

View file

@ -18,11 +18,7 @@ export default function SingleAlert({
key={dropItem.id}
>
<div className="flex-shrink-0">
<IconComponent
name="XCircle"
className="h-5 w-5 text-status-red"
aria-hidden="true"
/>
<IconComponent name="XCircle" className="h-5 w-5 text-status-red" />
</div>
<div className="ml-3">
<h3 className="text-sm font-medium text-error-foreground word-break-break-word">
@ -80,11 +76,7 @@ export default function SingleAlert({
className="inline-flex rounded-md p-1.5 text-status-red"
>
<span className="sr-only">Dismiss</span>
<IconComponent
name="X"
className="h-4 w-4 text-error-foreground"
aria-hidden="true"
/>
<IconComponent name="X" className="h-4 w-4 text-error-foreground" />
</button>
</div>
</div>
@ -95,11 +87,7 @@ export default function SingleAlert({
key={dropItem.id}
>
<div className="flex-shrink-0 cursor-help">
<IconComponent
name="Info"
className="h-5 w-5 text-status-blue"
aria-hidden="true"
/>
<IconComponent name="Info" className="h-5 w-5 text-status-blue" />
</div>
<div className="ml-3 flex-1 md:flex md:justify-between">
<p className="text-sm font-medium text-info-foreground">
@ -131,11 +119,7 @@ export default function SingleAlert({
className="inline-flex rounded-md p-1.5 text-info-foreground"
>
<span className="sr-only">Dismiss</span>
<IconComponent
name="X"
className="h-4 w-4 text-info-foreground"
aria-hidden="true"
/>
<IconComponent name="X" className="h-4 w-4 text-info-foreground" />
</button>
</div>
</div>
@ -149,7 +133,6 @@ export default function SingleAlert({
<IconComponent
name="CheckCircle2"
className="h-5 w-5 text-status-green"
aria-hidden="true"
/>
</div>
<div className="ml-3">
@ -173,7 +156,6 @@ export default function SingleAlert({
<IconComponent
name="X"
className="h-4 w-4 text-success-foreground"
aria-hidden="true"
/>
</button>
</div>

View file

@ -1,5 +1,6 @@
import { Cross2Icon } from "@radix-ui/react-icons";
import { useEffect, useState } from "react";
import { forwardRef, useEffect, useState } from "react";
import ShortUniqueId from "short-unique-id";
import IconComponent from "../../components/common/genericIconComponent";
import {
Popover,
@ -11,85 +12,90 @@ import useAlertStore from "../../stores/alertStore";
import { AlertDropdownType } from "../../types/alerts";
import SingleAlert from "./components/singleAlertComponent";
export default function AlertDropdown({
children,
notificationRef,
onClose,
}: AlertDropdownType): JSX.Element {
const notificationList = useAlertStore((state) => state.notificationList);
const clearNotificationList = useAlertStore(
(state) => state.clearNotificationList,
);
const removeFromNotificationList = useAlertStore(
(state) => state.removeFromNotificationList,
);
const setNotificationCenter = useAlertStore(
(state) => state.setNotificationCenter,
);
const AlertDropdown = forwardRef<HTMLDivElement, AlertDropdownType>(
function AlertDropdown({ children, notificationRef, onClose }, ref) {
const notificationList = useAlertStore((state) => state.notificationList);
const clearNotificationList = useAlertStore(
(state) => state.clearNotificationList,
);
const removeFromNotificationList = useAlertStore(
(state) => state.removeFromNotificationList,
);
const setNotificationCenter = useAlertStore(
(state) => state.setNotificationCenter,
);
const [open, setOpen] = useState(false);
const [open, setOpen] = useState(false);
useEffect(() => {
if (!open) {
onClose?.();
}
}, [open]);
useEffect(() => {
if (!open) {
onClose?.();
}
}, [open, onClose]);
return (
<Popover
data-testid="notification-dropdown"
open={open}
onOpenChange={(target) => {
setOpen(target);
if (target) {
setNotificationCenter(false);
}
}}
>
<PopoverTrigger asChild>{children}</PopoverTrigger>
<PopoverContent
ref={notificationRef}
data-testid="notification-dropdown-content"
className="noflow nowheel nopan nodelete nodrag z-10 flex h-[500px] w-[500px] flex-col"
const uid = new ShortUniqueId();
return (
<Popover
data-testid="notification-dropdown"
open={open}
onOpenChange={(target) => {
setOpen(target);
if (target) {
setNotificationCenter(false);
}
}}
>
<div className="text-md flex flex-row justify-between pl-3 font-medium text-foreground">
Notifications
<div className="flex gap-3 pr-3">
<button
className="text-foreground hover:text-status-red"
onClick={() => {
setOpen(false);
setTimeout(clearNotificationList, 100);
}}
>
<IconComponent name="Trash2" className="h-4 w-4" />
</button>
<button
className="text-foreground opacity-70 hover:opacity-100"
onClick={() => {
setOpen(false);
}}
>
<Cross2Icon className="h-4 w-4" />
</button>
</div>
</div>
<div className="text-high-foreground mt-3 flex h-full w-full flex-col overflow-y-scroll scrollbar-hide">
{notificationList.length !== 0 ? (
notificationList.map((alertItem, index) => (
<SingleAlert
key={alertItem.id}
dropItem={alertItem}
removeAlert={removeFromNotificationList}
/>
))
) : (
<div className="flex h-full w-full items-center justify-center pb-16 text-ring">
{ZERO_NOTIFICATIONS}
<PopoverTrigger asChild>{children}</PopoverTrigger>
<PopoverContent
ref={ref}
data-testid="notification-dropdown-content"
className="noflow nowheel nopan nodelete nodrag z-10 flex h-[500px] w-[500px] flex-col"
>
<div
ref={notificationRef}
className="text-md flex flex-row justify-between pl-3 font-medium text-foreground"
>
Notifications
<div className="flex gap-3 pr-3">
<button
className="text-foreground hover:text-status-red"
onClick={() => {
setOpen(false);
setTimeout(clearNotificationList, 100);
}}
>
<IconComponent name="Trash2" className="h-4 w-4" />
</button>
<button
className="text-foreground opacity-70 hover:opacity-100"
onClick={() => {
setOpen(false);
}}
>
<Cross2Icon className="h-4 w-4" />
</button>
</div>
)}
</div>
</PopoverContent>
</Popover>
);
}
</div>
<div className="text-high-foreground mt-3 flex h-full w-full flex-col overflow-y-scroll scrollbar-hide">
{notificationList.length !== 0 ? (
notificationList.map((alertItem) => (
<SingleAlert
key={uid.randomUUID(10)}
dropItem={alertItem}
removeAlert={removeFromNotificationList}
/>
))
) : (
<div className="flex h-full w-full items-center justify-center pb-16 text-ring">
{ZERO_NOTIFICATIONS}
</div>
)}
</div>
</PopoverContent>
</Popover>
);
},
);
export default AlertDropdown;

View file

@ -1,35 +1,35 @@
import { CustomLink } from "@/customization/components/custom-link";
import { Transition } from "@headlessui/react";
import { useEffect, useState } from "react";
import Markdown from "react-markdown";
import remarkGfm from "remark-gfm";
import IconComponent from "../../components/common/genericIconComponent";
import { NoticeAlertType } from "../../types/alerts";
export default function NoticeAlert({
title,
link,
list = [],
id,
link,
removeAlert,
}: NoticeAlertType): JSX.Element {
const [show, setShow] = useState(true);
useEffect(() => {
const timeoutId = setTimeout(() => {
setShow(false);
// Wait for the leave transition before calling removeAlert
if (show) {
setTimeout(() => {
removeAlert(id);
}, 500); // match the duration of the leave transition
}, 5000); // auto-dismiss alert after 5 seconds
return () => clearTimeout(timeoutId); // Cleanup timeout on component unmount or re-render
}, [id, removeAlert]);
setShow(false);
setTimeout(() => {
removeAlert(id);
}, 500);
}, 5000);
}
}, [id, removeAlert, show]);
const handleClick = () => {
setShow(false);
// Wait for the leave transition before calling removeAlert
setTimeout(() => {
removeAlert(id);
}, 500); // Ensure the alert is removed after the animation
}, 500);
};
return (

View file

@ -35,35 +35,65 @@ const DialogOverlay = React.forwardRef<
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
// Create a VisuallyHidden component for accessibility
const VisuallyHidden = React.forwardRef<
HTMLSpanElement,
React.HTMLAttributes<HTMLSpanElement>
>(({ children, ...props }, ref) => (
<span
ref={ref}
className="absolute h-px w-px overflow-hidden whitespace-nowrap border-0 p-0"
style={{ clip: "rect(0 0 0 0)", clipPath: "inset(50%)" }}
{...props}
>
{children}
</span>
));
VisuallyHidden.displayName = "VisuallyHidden";
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed z-50 flex w-full max-w-lg flex-col gap-4 rounded-xl border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%]",
className,
)}
{...props}
>
{children}
<ShadTooltip
styleClasses="z-50"
content="Close"
side="bottom"
avoidCollisions
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
hideTitle?: boolean;
}
>(({ className, children, hideTitle = false, ...props }, ref) => {
// Check if DialogTitle is included in children
const hasDialogTitle = React.Children.toArray(children).some(
(child) => React.isValidElement(child) && child.type === DialogTitle,
);
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed z-50 flex w-full max-w-lg flex-col gap-4 rounded-xl border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%]",
className,
)}
{...props}
>
<DialogPrimitive.Close className="absolute right-2 top-2 flex h-8 w-8 items-center justify-center rounded-sm ring-offset-background transition-opacity hover:bg-secondary-hover hover:text-accent-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<Cross2Icon className="h-[18px] w-[18px]" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</ShadTooltip>
</DialogPrimitive.Content>
</DialogPortal>
));
{!hasDialogTitle && (
<VisuallyHidden>
<DialogTitle>Dialog</DialogTitle>
</VisuallyHidden>
)}
{children}
<ShadTooltip
styleClasses="z-50"
content="Close"
side="bottom"
avoidCollisions
>
<DialogPrimitive.Close className="absolute right-2 top-2 flex h-8 w-8 items-center justify-center rounded-sm ring-offset-background transition-opacity hover:bg-secondary-hover hover:text-accent-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<Cross2Icon className="h-[18px] w-[18px]" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</ShadTooltip>
</DialogPrimitive.Content>
</DialogPortal>
);
});
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({
@ -126,4 +156,5 @@ export {
DialogHeader,
DialogTitle,
DialogTrigger,
VisuallyHidden,
};

View file

@ -111,11 +111,7 @@ const AnimationComponent: React.FC<{
{segment}
</motion.span>
) : per === "word" ? (
<motion.span
aria-hidden="true"
variants={variants}
className="inline-block whitespace-pre"
>
<motion.span variants={variants} className="inline-block whitespace-pre">
{segment}
</motion.span>
) : (
@ -123,7 +119,6 @@ const AnimationComponent: React.FC<{
{segment.split("").map((char, charIndex) => (
<motion.span
key={`char-${charIndex}`}
aria-hidden="true"
variants={variants}
className="inline-block whitespace-pre"
>
@ -151,7 +146,7 @@ AnimationComponent.displayName = "AnimationComponent";
export function TextEffect({
children,
per = "word",
as = "p",
as = "span",
variants,
className,
preset,
@ -224,7 +219,7 @@ export function TextEffect({
export function TextEffectPerChar({ children }: { children: string }) {
return (
<TextEffect per="char" preset="fade">
<TextEffect per="char" preset="fade" as="span">
{children}
</TextEffect>
);

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -9,6 +9,7 @@ export type NoticeAlertType = {
link?: string;
id: string;
removeAlert: (id: string) => void;
list?: Array<string>;
};
export type SuccessAlertType = {
title: string;

View file

@ -160,6 +160,7 @@ import {
Pen,
Pencil,
PencilLine,
PencilRuler,
PieChart,
Pin,
Plane,
@ -857,6 +858,7 @@ export const nodeIconsLucide: iconsType = {
UserMinus2,
UserPlus2,
Pencil,
PencilRuler,
ChevronsRight,
ChevronsLeft,
EyeOff,