feat(FE-Storyless): add FieldMessage component for consistent field feedback across inputs

This commit is contained in:
rstubryan
2025-10-20 18:54:02 +07:00
parent ba9ae07455
commit 1bcfd9bbb4
8 changed files with 195 additions and 96 deletions
+65
View File
@@ -0,0 +1,65 @@
'use client';
import { ReactNode } from 'react';
import { cn } from '@/lib/helper';
type FieldMessageTone = 'error' | 'info' | 'success';
export interface FieldMessageProps {
message?: ReactNode;
tone?: FieldMessageTone;
isVisible?: boolean;
persistent?: boolean;
className?: string;
ariaLive?: 'off' | 'polite' | 'assertive';
}
const toneClassName: Record<FieldMessageTone, string> = {
error: 'text-error',
info: 'text-base-content/60',
success: 'text-success',
};
/**
* Shared helper to render bottom field feedback without causing layout shift.
* Keeps a minimal slot height, but expands when the content wraps onto multiple lines.
*/
export const FieldMessage = ({
message,
tone = 'info',
isVisible,
persistent = true,
className,
ariaLive,
}: FieldMessageProps) => {
const hasMessage = Boolean(message);
const visible = isVisible ?? hasMessage;
const liveRegion = ariaLive ?? (tone === 'error' ? 'assertive' : 'polite');
return (
<div
aria-live={liveRegion}
aria-hidden={!visible && !hasMessage}
className={cn(
'relative w-full text-sm leading-5 transition-[opacity,transform] duration-150 ease-out',
persistent && 'min-h-[1.25rem]',
className
)}
>
<span
className={cn(
'block whitespace-pre-line',
toneClassName[tone],
visible
? 'opacity-100 translate-y-0'
: 'opacity-0 -translate-y-1 pointer-events-none'
)}
>
{visible || persistent ? (message ?? '\u00A0') : message}
</span>
</div>
);
};
export default FieldMessage;
+9 -5
View File
@@ -2,6 +2,7 @@ import { Ref } from 'react';
import { cn } from '@/lib/helper';
import { TextInputProps } from '@/components/input/TextInput';
import FieldMessage from '@/components/helper/FieldMessage';
interface FileInputProps
extends Omit<
@@ -37,6 +38,9 @@ const FileInput = ({
onBlur,
readOnly = false,
}: FileInputProps) => {
const showErrorMessage = Boolean(isError && errorMessage);
const feedbackMessage = showErrorMessage ? errorMessage : bottomLabel;
return (
<div
className={cn(
@@ -76,11 +80,11 @@ const FileInput = ({
readOnly={readOnly}
/>
{bottomLabel && (
<p className='w-full text-sm opacity-60'>{bottomLabel}</p>
)}
{isError && <p className='w-full text-sm text-error'>{errorMessage}</p>}
<FieldMessage
message={feedbackMessage}
tone={showErrorMessage ? 'error' : 'info'}
isVisible={showErrorMessage || Boolean(bottomLabel)}
/>
</div>
);
};
+43 -14
View File
@@ -11,6 +11,7 @@ import {
import { Icon } from '@iconify/react';
import { cn } from '@/lib/helper';
import FieldMessage from '@/components/helper/FieldMessage';
// Utility Functions
const formatNumber = (
@@ -25,7 +26,10 @@ const formatNumber = (
if (isNaN(numValue)) return '';
const parts = numValue.toFixed(decimals).split('.');
const integerPart = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, thousandSeparator);
const integerPart = parts[0].replace(
/\B(?=(\d{3})+(?!\d))/g,
thousandSeparator
);
const decimalPart = parts[1];
return decimals > 0 && decimalPart
@@ -180,11 +184,13 @@ const NumberInput = ({
const [displayValue, setDisplayValue] = useState<string>('');
// Determine if decimals are allowed based on maskType
const allowDecimal = maskType === 'decimal' || maskType === 'weight' || decimals > 0;
const allowDecimal =
maskType === 'decimal' || maskType === 'weight' || decimals > 0;
// Format value for display based on maskType
const getFormattedValue = (rawValue: number | string): string => {
if (rawValue === '' || rawValue === null || rawValue === undefined) return '';
if (rawValue === '' || rawValue === null || rawValue === undefined)
return '';
switch (maskType) {
case 'currency':
@@ -193,7 +199,12 @@ const NumberInput = ({
return formatWeight(rawValue, weightUnit, decimals);
case 'decimal':
case 'number':
return formatNumber(rawValue, decimals, thousandSeparator, decimalSeparator);
return formatNumber(
rawValue,
decimals,
thousandSeparator,
decimalSeparator
);
default:
return String(rawValue);
}
@@ -216,10 +227,18 @@ const NumberInput = ({
}
// Clean input
const cleaned = cleanNumericInput(inputValue, allowDecimal, decimalSeparator);
const cleaned = cleanNumericInput(
inputValue,
allowDecimal,
decimalSeparator
);
// Parse to number
let numericValue = parseNumber(cleaned, thousandSeparator, decimalSeparator);
let numericValue = parseNumber(
cleaned,
thousandSeparator,
decimalSeparator
);
// Apply validation
if (!allowNegative && numericValue < 0) {
@@ -262,7 +281,11 @@ const NumberInput = ({
const handleIncrement = () => {
if (disabled || readOnly) return;
const currentValue = parseNumber(displayValue, thousandSeparator, decimalSeparator);
const currentValue = parseNumber(
displayValue,
thousandSeparator,
decimalSeparator
);
let newValue = currentValue + step;
// Apply max validation
@@ -296,7 +319,11 @@ const NumberInput = ({
const handleDecrement = () => {
if (disabled || readOnly) return;
const currentValue = parseNumber(displayValue, thousandSeparator, decimalSeparator);
const currentValue = parseNumber(
displayValue,
thousandSeparator,
decimalSeparator
);
let newValue = currentValue - step;
// Apply min validation (prevent negative if not allowed)
@@ -329,6 +356,9 @@ const NumberInput = ({
}
};
const showErrorMessage = Boolean(isError && errorMessage);
const feedbackMessage = showErrorMessage ? errorMessage : bottomLabel;
return (
<div
className={cn(
@@ -431,12 +461,11 @@ const NumberInput = ({
)}
</div>
{!isError && bottomLabel && (
<p className='w-full text-sm opacity-60'>{bottomLabel}</p>
)}
{isError && errorMessage && (
<p className='w-full text-sm text-error'>{errorMessage}</p>
)}
<FieldMessage
message={feedbackMessage}
tone={showErrorMessage ? 'error' : 'info'}
isVisible={showErrorMessage || Boolean(bottomLabel)}
/>
</div>
);
};
+8 -9
View File
@@ -2,6 +2,7 @@
import { ChangeEventHandler, ReactNode } from 'react';
import { cn } from '@/lib/helper';
import FieldMessage from '@/components/helper/FieldMessage';
export interface RadioOption {
label: string;
@@ -47,6 +48,8 @@ const RadioInput = ({
onChange,
onBlur,
}: RadioInputProps) => {
const showErrorMessage = Boolean(isError && errorMessage);
const feedbackMessage = showErrorMessage ? errorMessage : bottomLabel;
return (
<div className={cn('w-full flex flex-col gap-2', className?.wrapper)}>
{/* Label atas */}
@@ -97,15 +100,11 @@ const RadioInput = ({
))}
</div>
{/* Label bawah */}
{!isError && bottomLabel && (
<p className='text-sm opacity-60'>{bottomLabel}</p>
)}
{/* Pesan error */}
{isError && errorMessage && (
<p className='text-sm text-error'>{errorMessage}</p>
)}
<FieldMessage
message={feedbackMessage}
tone={showErrorMessage ? 'error' : 'info'}
isVisible={showErrorMessage || Boolean(bottomLabel)}
/>
</div>
);
};
+15 -21
View File
@@ -1,12 +1,6 @@
'use client';
import {
ComponentType,
ReactNode,
useEffect,
useMemo,
useState,
} from 'react';
import { ComponentType, ReactNode, useEffect, useMemo, useState } from 'react';
import Select, {
OptionProps,
GroupBase,
@@ -18,6 +12,7 @@ import CreatableSelect from 'react-select/creatable';
import makeAnimated from 'react-select/animated';
import { useDebounce } from 'use-debounce';
import { cn } from '@/lib/helper';
import FieldMessage from '@/components/helper/FieldMessage';
export interface OptionType {
value: string | number;
@@ -98,10 +93,7 @@ const SelectInput = <T extends OptionType>(props: SelectInputProps<T>) => {
return { ...base, IndicatorSeparator: () => null };
}, [isAnimated]);
const internalInputChangeHandler = (
val: string,
meta: InputActionMeta
) => {
const internalInputChangeHandler = (val: string, meta: InputActionMeta) => {
if (meta.action === 'input-change') setInternalInputValue(val);
if (meta.action === 'menu-close') setInternalInputValue('');
};
@@ -113,9 +105,7 @@ const SelectInput = <T extends OptionType>(props: SelectInputProps<T>) => {
const SelectComponent = createables ? CreatableSelect : Select;
/** 🎯 handleChange tanpa any */
const handleChange = (
val: MultiValue<T> | SingleValue<T>
): void => {
const handleChange = (val: MultiValue<T> | SingleValue<T>): void => {
if (!val) {
onChange?.(null);
return;
@@ -128,6 +118,9 @@ const SelectInput = <T extends OptionType>(props: SelectInputProps<T>) => {
}
};
const showErrorMessage = Boolean(isError && errorMessage);
const feedbackMessage = showErrorMessage ? errorMessage : bottomLabel;
return (
<div
className={cn(
@@ -145,15 +138,15 @@ const SelectInput = <T extends OptionType>(props: SelectInputProps<T>) => {
>
{label}
{required && (
<span className="tooltip tooltip-error" data-tip="required">
<span className="text-error"> *</span>
<span className='tooltip tooltip-error' data-tip='required'>
<span className='text-error'> *</span>
</span>
)}
</span>
)}
<SelectComponent<T, boolean, GroupBase<T>>
instanceId="select"
instanceId='select'
value={value ?? (isMulti ? [] : null)}
onChange={handleChange}
options={options}
@@ -225,10 +218,11 @@ const SelectInput = <T extends OptionType>(props: SelectInputProps<T>) => {
}}
/>
{isError && <p className="w-full text-sm text-error">{errorMessage}</p>}
{!isError && bottomLabel && (
<p className="w-full text-sm opacity-60">{bottomLabel}</p>
)}
<FieldMessage
message={feedbackMessage}
tone={showErrorMessage ? 'error' : 'info'}
isVisible={showErrorMessage || Boolean(bottomLabel)}
/>
</div>
);
};
+9 -5
View File
@@ -2,6 +2,7 @@
import React, { useState, KeyboardEvent, ChangeEvent, useEffect } from 'react';
import { cn } from '@/lib/helper';
import FieldMessage from '@/components/helper/FieldMessage';
export interface TagInputProps {
label?: string;
@@ -73,6 +74,9 @@ const TagInput: React.FC<TagInputProps> = ({
setInputValue(e.target.value);
};
const showErrorMessage = Boolean(isError && errorMessage);
const feedbackMessage = showErrorMessage ? errorMessage : bottomLabel;
return (
<div
className={cn(
@@ -157,11 +161,11 @@ const TagInput: React.FC<TagInputProps> = ({
)}
</div>
{/* Bottom label or error message */}
{!isError && bottomLabel && (
<p className='w-full text-sm opacity-60'>{bottomLabel}</p>
)}
{isError && <p className='w-full text-sm text-error'>{errorMessage}</p>}
<FieldMessage
message={feedbackMessage}
tone={showErrorMessage ? 'error' : 'info'}
isVisible={showErrorMessage || Boolean(bottomLabel)}
/>
</div>
);
};
+37 -36
View File
@@ -1,12 +1,9 @@
'use client';
import {
ChangeEventHandler,
FocusEventHandler,
ReactNode,
} from 'react';
import { ChangeEventHandler, FocusEventHandler, ReactNode } from 'react';
import { cn } from '@/lib/helper';
import FieldMessage from '@/components/helper/FieldMessage';
export interface TextAreaProps {
label?: string;
@@ -52,8 +49,11 @@ const TextArea = ({
onBlur,
readOnly = false,
isLoading = false,
rows = 3
rows = 3,
}: TextAreaProps) => {
const showErrorMessage = Boolean(isError && errorMessage);
const feedbackMessage = showErrorMessage ? errorMessage : bottomLabel;
return (
<div
className={cn(
@@ -83,40 +83,41 @@ const TextArea = ({
)}
</label>
)}
{startAdornment && startAdornment}
{startAdornment && startAdornment}
<textarea
className={cn(
'input h-auto px-4 py-2 text-base font-normal leading-6 w-full rounded-lg! outline-none! transition-all',
{
'border-error': isError,
'border-success!': isValid,
},
className?.inputWrapper
)}
id={name}
name={name}
placeholder={placeholder}
value={value}
rows={rows}
onChange={onChange}
onBlur={onBlur}
disabled={disabled}
readOnly={readOnly}
/>
{(isLoading || endAdornment) && (
<div className='flex flex-row gap-2'>
{isLoading && <span className='loading loading-spinner' />}
{endAdornment && endAdornment}
</div>
<textarea
className={cn(
'input h-auto px-4 py-2 text-base font-normal leading-6 w-full rounded-lg! outline-none! transition-all',
{
'border-error': isError,
'border-success!': isValid,
},
className?.inputWrapper
)}
id={name}
name={name}
placeholder={placeholder}
value={value}
rows={rows}
onChange={onChange}
onBlur={onBlur}
disabled={disabled}
readOnly={readOnly}
/>
{!isError && bottomLabel && (
<p className='w-full text-sm opacity-60'>{bottomLabel}</p>
{(isLoading || endAdornment) && (
<div className='flex flex-row gap-2'>
{isLoading && <span className='loading loading-spinner' />}
{endAdornment && endAdornment}
</div>
)}
{isError && <p className='w-full text-sm text-error'>{errorMessage}</p>}
<FieldMessage
message={feedbackMessage}
tone={showErrorMessage ? 'error' : 'info'}
isVisible={showErrorMessage || Boolean(bottomLabel)}
/>
</div>
);
};
+9 -6
View File
@@ -8,6 +8,7 @@ import {
} from 'react';
import { cn } from '@/lib/helper';
import FieldMessage from '@/components/helper/FieldMessage';
export interface TextInputProps {
type?: HTMLInputTypeAttribute;
@@ -55,6 +56,9 @@ const TextInput = ({
readOnly = false,
isLoading = false,
}: TextInputProps) => {
const showErrorMessage = Boolean(isError && errorMessage);
const feedbackMessage = showErrorMessage ? errorMessage : bottomLabel;
return (
<div
className={cn(
@@ -119,12 +123,11 @@ const TextInput = ({
)}
</div>
{!isError && bottomLabel && (
<p className='w-full text-sm opacity-60'>{bottomLabel}</p>
)}
{isError && errorMessage && (
<p className='w-full text-sm text-error'>{errorMessage}</p>
)}
<FieldMessage
message={feedbackMessage}
tone={showErrorMessage ? 'error' : 'info'}
isVisible={showErrorMessage || Boolean(bottomLabel)}
/>
</div>
);
};