refactor: improved error ui

This commit is contained in:
Dhruwang
2025-12-23 13:57:38 +05:30
parent e61ab22ed7
commit d25b428e51
12 changed files with 79 additions and 78 deletions

View File

@@ -69,7 +69,7 @@ function Consent({
/>
{/* Consent Checkbox */}
<div className="relative space-y-2">
<div className="relative">
<ElementError errorMessage={errorMessage} dir={dir} />
<label
@@ -87,7 +87,6 @@ function Consent({
onCheckedChange={handleCheckboxChange}
disabled={disabled}
aria-invalid={Boolean(errorMessage)}
required={required}
/>
{/* need to use style here because tailwind is not able to use css variables for font size and weight */}
<span

View File

@@ -138,7 +138,6 @@ interface UploadAreaProps {
placeholderText: string;
allowMultiple: boolean;
acceptAttribute?: string;
required: boolean;
disabled: boolean;
dir: "ltr" | "rtl" | "auto";
onFileChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
@@ -153,7 +152,6 @@ function UploadArea({
placeholderText,
allowMultiple,
acceptAttribute,
required,
disabled,
dir,
onFileChange,
@@ -201,7 +199,6 @@ function UploadArea({
accept={acceptAttribute}
onChange={onFileChange}
disabled={disabled}
required={required}
dir={dir}
aria-label="File upload"
aria-describedby={`${inputId}-label`}
@@ -293,8 +290,8 @@ function FileUpload({
<div
className={cn(
"w-input px-input-x py-input-y rounded-input relative flex flex-col items-center justify-center border-2 border-dashed transition-colors",
errorMessage ? "border-destructive" : "border-input-border bg-accent",
"w-input px-input-x py-input-y rounded-input bg-accent relative flex flex-col items-center justify-center border-2 border-dashed transition-colors",
errorMessage ? "border-destructive" : "border-input-border",
disabled && "cursor-not-allowed opacity-50"
)}>
<UploadedFilesList files={uploadedFiles} disabled={disabled} onDelete={handleDeleteFile} />
@@ -316,7 +313,6 @@ function FileUpload({
placeholderText={placeholderText}
allowMultiple={allowMultiple}
acceptAttribute={acceptAttribute}
required={required}
disabled={disabled}
dir={dir}
onFileChange={handleFileChange}

View File

@@ -66,10 +66,6 @@ function Matrix({
// Ensure value is always an object (value already has default of {})
const selectedValues = value;
// Check which rows have errors (no selection when required)
const hasError = Boolean(errorMessage);
const rowsWithErrors = hasError && required ? rows.filter((row) => !selectedValues[row.id]) : [];
const handleRowChange = (rowId: string, columnId: string): void => {
// Toggle: if same column is selected, deselect it
if (selectedValues[rowId] === columnId) {
@@ -116,7 +112,6 @@ function Matrix({
{rows.map((row, index) => {
const rowGroupId = `${inputId}-row-${row.id}`;
const selectedColumnId = selectedValues[row.id];
const rowHasError = rowsWithErrors.includes(row);
const baseBgColor = index % 2 === 0 ? "bg-input-bg" : "bg-transparent";
return (
@@ -131,14 +126,11 @@ function Matrix({
disabled={disabled}
aria-required={required}
aria-invalid={Boolean(errorMessage)}>
<tr className={cn("relative", baseBgColor, rowHasError ? "bg-destructive-muted" : "")}>
<tr className={cn("relative", baseBgColor)}>
{/* Row label */}
<th scope="row" className={cn("p-2 align-middle", !rowHasError && "rounded-l-input")}>
<th scope="row" className={cn("rounded-l-input p-2 align-middle")}>
<div className="flex flex-col gap-0 leading-none">
<Label>{row.label}</Label>
{rowHasError ? (
<span className="text-destructive text-xs font-normal">Select one option</span>
) : null}
</div>
</th>
{/* Column options for this row */}
@@ -149,14 +141,10 @@ function Matrix({
return (
<td
key={column.id}
className={cn(
"p-2 text-center align-middle",
isLastColumn && !rowHasError && "rounded-r-input"
)}>
className={cn("p-2 text-center align-middle", isLastColumn && "rounded-r-input")}>
<Label htmlFor={cellId} className="flex cursor-pointer justify-center">
<RadioGroupItem
value={column.id}
required={required}
id={cellId}
disabled={disabled}
aria-label={`${row.label}-${column.label}`}

View File

@@ -153,7 +153,6 @@ function NPS({
handleSelect(number);
}}
disabled={disabled}
aria-required={required}
className="sr-only"
aria-label={`Rate ${String(number)} out of 10`}
/>
@@ -178,7 +177,7 @@ function NPS({
/>
{/* NPS Options */}
<div className="relative space-y-2">
<div className="relative">
<ElementError errorMessage={errorMessage} dir={dir} />
<fieldset className="w-full px-[2px]">
<legend className="sr-only">NPS rating options</legend>

View File

@@ -1,4 +1,5 @@
import { useState } from "react";
import { ElementError } from "@/components/general/element-error";
import { ElementHeader } from "@/components/general/element-header";
import { Input } from "@/components/general/input";
import { Textarea } from "@/components/general/textarea";
@@ -75,39 +76,41 @@ function OpenText({
imageUrl={imageUrl}
videoUrl={videoUrl}
/>
{/* Input or Textarea */}
<div className="space-y-1">
{longAnswer ? (
<Textarea
id={inputId}
placeholder={placeholder}
value={value}
onChange={handleChange}
aria-required={required}
dir={dir}
rows={rows}
disabled={disabled}
errorMessage={errorMessage}
minLength={charLimit?.min}
maxLength={charLimit?.max}
/>
) : (
<Input
id={inputId}
type={inputType}
placeholder={placeholder}
value={value}
onChange={handleChange}
aria-required={required}
dir={dir}
disabled={disabled}
errorMessage={errorMessage}
minLength={charLimit?.min}
maxLength={charLimit?.max}
/>
)}
{renderCharLimit()}
<div className="relative">
<ElementError errorMessage={errorMessage} />
{/* Input or Textarea */}
<div className="space-y-1">
{longAnswer ? (
<Textarea
id={inputId}
placeholder={placeholder}
value={value}
onChange={handleChange}
aria-required={required}
dir={dir}
rows={rows}
disabled={disabled}
errorMessage={errorMessage}
minLength={charLimit?.min}
maxLength={charLimit?.max}
/>
) : (
<Input
id={inputId}
type={inputType}
placeholder={placeholder}
value={value}
onChange={handleChange}
aria-required={required}
dir={dir}
disabled={disabled}
errorMessage={errorMessage}
minLength={charLimit?.min}
maxLength={charLimit?.max}
/>
)}
{renderCharLimit()}
</div>
</div>
</div>
);

View File

@@ -278,7 +278,6 @@ function Rating({
handleSelect(number);
}}
disabled={disabled}
aria-required={required}
className="sr-only"
aria-label={`Rate ${String(number)} out of ${String(range)}`}
/>
@@ -329,7 +328,6 @@ function Rating({
handleSelect(number);
}}
disabled={disabled}
aria-required={required}
className="sr-only"
aria-label={`Rate ${String(number)} out of ${String(range)} stars`}
/>
@@ -388,7 +386,6 @@ function Rating({
handleSelect(number);
}}
disabled={disabled}
aria-required={required}
className="sr-only"
aria-label={`Rate ${String(number)} out of ${String(range)}`}
/>
@@ -415,7 +412,7 @@ function Rating({
/>
{/* Rating Options */}
<div className="relative space-y-2">
<div className="relative">
<ElementError errorMessage={errorMessage} dir={dir} />
<fieldset className="w-full">
<legend className="sr-only">Rating options</legend>

View File

@@ -1,5 +1,4 @@
import * as React from "react";
import { ElementError } from "@/components/general/element-error";
import { cn } from "@/lib/utils";
interface InputProps extends React.ComponentProps<"input"> {
@@ -10,21 +9,17 @@ interface InputProps extends React.ComponentProps<"input"> {
}
const Input = React.forwardRef<HTMLInputElement, InputProps>(function Input(
{ className, type, errorMessage, dir, ...props },
{ className, type, dir, ...props },
ref
): React.JSX.Element {
const hasError = Boolean(errorMessage);
return (
<div className="relative space-y-1">
<ElementError errorMessage={errorMessage} dir={dir} />
<input
ref={ref}
type={type}
dir={dir}
data-slot="input"
style={{ fontSize: "var(--fb-input-font-size)" }}
aria-invalid={hasError || undefined}
className={cn(
// Layout and behavior
"flex min-w-0 border transition-[color,box-shadow] outline-none",

View File

@@ -1,4 +1,3 @@
import { ElementError } from "@/components/general/element-error";
import { cn } from "@/lib/utils";
type TextareaProps = React.ComponentProps<"textarea"> & {
@@ -6,17 +5,13 @@ type TextareaProps = React.ComponentProps<"textarea"> & {
errorMessage?: string;
};
function Textarea({ className, errorMessage, dir = "auto", ...props }: TextareaProps): React.JSX.Element {
const hasError = Boolean(errorMessage);
function Textarea({ className, dir = "auto", ...props }: TextareaProps): React.JSX.Element {
return (
<div className="relative space-y-2">
<ElementError errorMessage={errorMessage} dir={dir} />
<textarea
data-slot="textarea"
style={{ fontSize: "var(--fb-input-font-size)" }}
dir={dir}
aria-invalid={hasError || undefined}
className={cn(
"w-input bg-input-bg border-input-border rounded-input font-input font-input-weight px-input-x py-input-y shadow-input placeholder:text-input-placeholder placeholder:opacity-input-placeholder focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 text-input text-input-text flex field-sizing-content min-h-16 border transition-[color,box-shadow] outline-none placeholder:text-sm focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className

View File

@@ -68,7 +68,6 @@ export function PictureSelectionElement({
e.preventDefault();
if (element.required) {
if (element.allowMulti) {
console.log("currentValue", currentValue);
if (!currentValue || !Array.isArray(currentValue) || currentValue.length === 0) {
setErrorMessage(t("errors.please_select_an_option"));
return;

View File

@@ -1,4 +1,5 @@
import { useState } from "preact/hooks";
import { useTranslation } from "react-i18next";
import { Rating } from "@formbricks/survey-ui";
import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses";
import type { TSurveyRatingElement } from "@formbricks/types/surveys/elements";
@@ -12,7 +13,6 @@ interface RatingElementProps {
languageCode: string;
ttc: TResponseTtc;
setTtc: (ttc: TResponseTtc) => void;
autoFocusEnabled: boolean;
currentElementId: string;
dir?: "ltr" | "rtl" | "auto";
}
@@ -29,16 +29,28 @@ export function RatingElement({
}: RatingElementProps) {
const [startTime, setStartTime] = useState(performance.now());
const isCurrent = element.id === currentElementId;
const [errorMessage, setErrorMessage] = useState<string | undefined>(undefined);
useTtc(element.id, ttc, setTtc, startTime, setStartTime, isCurrent);
const { t } = useTranslation();
const handleChange = (ratingValue: number) => {
setErrorMessage(undefined);
onChange({ [element.id]: ratingValue });
const updatedTtcObj = getUpdatedTtc(ttc, element.id, performance.now() - startTime);
setTtc(updatedTtcObj);
};
const validateRequired = (): boolean => {
if (element.required && !value) {
setErrorMessage(t("errors.please_select_an_option"));
return false;
}
return true;
};
const handleSubmit = (e: Event) => {
e.preventDefault();
if (!validateRequired()) return;
const updatedTtcObj = getUpdatedTtc(ttc, element.id, performance.now() - startTime);
setTtc(updatedTtcObj);
};
@@ -61,6 +73,7 @@ export function RatingElement({
dir={dir}
imageUrl={element.imageUrl}
videoUrl={element.videoUrl}
errorMessage={errorMessage}
/>
</form>
);

View File

@@ -1,11 +1,12 @@
import { useEffect, useRef, useState } from "preact/hooks";
import { type TJsFileUploadParams } from "@formbricks/types/js";
import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses";
import { type TResponseData, TResponseDataValue, type TResponseTtc } from "@formbricks/types/responses";
import { type TUploadFileConfig } from "@formbricks/types/storage";
import { TSurveyBlock } from "@formbricks/types/surveys/blocks";
import {
TSurveyElement,
TSurveyElementTypeEnum,
TSurveyMatrixElement,
TSurveyRankingElement,
} from "@formbricks/types/surveys/elements";
import { BackButton } from "@/components/buttons/back-button";
@@ -157,6 +158,13 @@ export function BlockConditional({
);
};
const hasUnansweredRows = (responseData: TResponseDataValue, element: TSurveyMatrixElement): boolean => {
return element.rows.some((row) => {
const rowLabel = getLocalizedValue(row.label, languageCode);
return !responseData?.[rowLabel as keyof typeof responseData];
});
};
// Validate a single element's form
const validateElementForm = (element: TSurveyElement, form: HTMLFormElement): boolean => {
const response = value[element.id];
@@ -166,6 +174,16 @@ export function BlockConditional({
return false;
}
if (
element.type === TSurveyElementTypeEnum.Matrix &&
element.required &&
response &&
hasUnansweredRows(response, element)
) {
form.requestSubmit();
return false;
}
// For other element types, check if required fields are empty
// CTA elements should not block navigation even if marked required (as they are informational)
if (element.type !== TSurveyElementTypeEnum.CTA && element.required && isEmptyResponse(response)) {

View File

@@ -196,7 +196,6 @@ export function ElementConditional({
languageCode={languageCode}
ttc={ttc}
setTtc={wrappedSetTtc}
autoFocusEnabled={autoFocusEnabled}
currentElementId={currentElementId}
dir={dir}
/>