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 { cn } from '@/lib/helper';
import { TextInputProps } from '@/components/input/TextInput'; import { TextInputProps } from '@/components/input/TextInput';
import FieldMessage from '@/components/helper/FieldMessage';
interface FileInputProps interface FileInputProps
extends Omit< extends Omit<
@@ -37,6 +38,9 @@ const FileInput = ({
onBlur, onBlur,
readOnly = false, readOnly = false,
}: FileInputProps) => { }: FileInputProps) => {
const showErrorMessage = Boolean(isError && errorMessage);
const feedbackMessage = showErrorMessage ? errorMessage : bottomLabel;
return ( return (
<div <div
className={cn( className={cn(
@@ -76,11 +80,11 @@ const FileInput = ({
readOnly={readOnly} readOnly={readOnly}
/> />
{bottomLabel && ( <FieldMessage
<p className='w-full text-sm opacity-60'>{bottomLabel}</p> message={feedbackMessage}
)} tone={showErrorMessage ? 'error' : 'info'}
isVisible={showErrorMessage || Boolean(bottomLabel)}
{isError && <p className='w-full text-sm text-error'>{errorMessage}</p>} />
</div> </div>
); );
}; };
+43 -14
View File
@@ -11,6 +11,7 @@ import {
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
import { cn } from '@/lib/helper'; import { cn } from '@/lib/helper';
import FieldMessage from '@/components/helper/FieldMessage';
// Utility Functions // Utility Functions
const formatNumber = ( const formatNumber = (
@@ -25,7 +26,10 @@ const formatNumber = (
if (isNaN(numValue)) return ''; if (isNaN(numValue)) return '';
const parts = numValue.toFixed(decimals).split('.'); 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]; const decimalPart = parts[1];
return decimals > 0 && decimalPart return decimals > 0 && decimalPart
@@ -180,11 +184,13 @@ const NumberInput = ({
const [displayValue, setDisplayValue] = useState<string>(''); const [displayValue, setDisplayValue] = useState<string>('');
// Determine if decimals are allowed based on maskType // 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 // Format value for display based on maskType
const getFormattedValue = (rawValue: number | string): string => { const getFormattedValue = (rawValue: number | string): string => {
if (rawValue === '' || rawValue === null || rawValue === undefined) return ''; if (rawValue === '' || rawValue === null || rawValue === undefined)
return '';
switch (maskType) { switch (maskType) {
case 'currency': case 'currency':
@@ -193,7 +199,12 @@ const NumberInput = ({
return formatWeight(rawValue, weightUnit, decimals); return formatWeight(rawValue, weightUnit, decimals);
case 'decimal': case 'decimal':
case 'number': case 'number':
return formatNumber(rawValue, decimals, thousandSeparator, decimalSeparator); return formatNumber(
rawValue,
decimals,
thousandSeparator,
decimalSeparator
);
default: default:
return String(rawValue); return String(rawValue);
} }
@@ -216,10 +227,18 @@ const NumberInput = ({
} }
// Clean input // Clean input
const cleaned = cleanNumericInput(inputValue, allowDecimal, decimalSeparator); const cleaned = cleanNumericInput(
inputValue,
allowDecimal,
decimalSeparator
);
// Parse to number // Parse to number
let numericValue = parseNumber(cleaned, thousandSeparator, decimalSeparator); let numericValue = parseNumber(
cleaned,
thousandSeparator,
decimalSeparator
);
// Apply validation // Apply validation
if (!allowNegative && numericValue < 0) { if (!allowNegative && numericValue < 0) {
@@ -262,7 +281,11 @@ const NumberInput = ({
const handleIncrement = () => { const handleIncrement = () => {
if (disabled || readOnly) return; if (disabled || readOnly) return;
const currentValue = parseNumber(displayValue, thousandSeparator, decimalSeparator); const currentValue = parseNumber(
displayValue,
thousandSeparator,
decimalSeparator
);
let newValue = currentValue + step; let newValue = currentValue + step;
// Apply max validation // Apply max validation
@@ -296,7 +319,11 @@ const NumberInput = ({
const handleDecrement = () => { const handleDecrement = () => {
if (disabled || readOnly) return; if (disabled || readOnly) return;
const currentValue = parseNumber(displayValue, thousandSeparator, decimalSeparator); const currentValue = parseNumber(
displayValue,
thousandSeparator,
decimalSeparator
);
let newValue = currentValue - step; let newValue = currentValue - step;
// Apply min validation (prevent negative if not allowed) // 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 ( return (
<div <div
className={cn( className={cn(
@@ -431,12 +461,11 @@ const NumberInput = ({
)} )}
</div> </div>
{!isError && bottomLabel && ( <FieldMessage
<p className='w-full text-sm opacity-60'>{bottomLabel}</p> message={feedbackMessage}
)} tone={showErrorMessage ? 'error' : 'info'}
{isError && errorMessage && ( isVisible={showErrorMessage || Boolean(bottomLabel)}
<p className='w-full text-sm text-error'>{errorMessage}</p> />
)}
</div> </div>
); );
}; };
+8 -9
View File
@@ -2,6 +2,7 @@
import { ChangeEventHandler, ReactNode } from 'react'; import { ChangeEventHandler, ReactNode } from 'react';
import { cn } from '@/lib/helper'; import { cn } from '@/lib/helper';
import FieldMessage from '@/components/helper/FieldMessage';
export interface RadioOption { export interface RadioOption {
label: string; label: string;
@@ -47,6 +48,8 @@ const RadioInput = ({
onChange, onChange,
onBlur, onBlur,
}: RadioInputProps) => { }: RadioInputProps) => {
const showErrorMessage = Boolean(isError && errorMessage);
const feedbackMessage = showErrorMessage ? errorMessage : bottomLabel;
return ( return (
<div className={cn('w-full flex flex-col gap-2', className?.wrapper)}> <div className={cn('w-full flex flex-col gap-2', className?.wrapper)}>
{/* Label atas */} {/* Label atas */}
@@ -97,15 +100,11 @@ const RadioInput = ({
))} ))}
</div> </div>
{/* Label bawah */} <FieldMessage
{!isError && bottomLabel && ( message={feedbackMessage}
<p className='text-sm opacity-60'>{bottomLabel}</p> tone={showErrorMessage ? 'error' : 'info'}
)} isVisible={showErrorMessage || Boolean(bottomLabel)}
/>
{/* Pesan error */}
{isError && errorMessage && (
<p className='text-sm text-error'>{errorMessage}</p>
)}
</div> </div>
); );
}; };
+15 -21
View File
@@ -1,12 +1,6 @@
'use client'; 'use client';
import { import { ComponentType, ReactNode, useEffect, useMemo, useState } from 'react';
ComponentType,
ReactNode,
useEffect,
useMemo,
useState,
} from 'react';
import Select, { import Select, {
OptionProps, OptionProps,
GroupBase, GroupBase,
@@ -18,6 +12,7 @@ import CreatableSelect from 'react-select/creatable';
import makeAnimated from 'react-select/animated'; import makeAnimated from 'react-select/animated';
import { useDebounce } from 'use-debounce'; import { useDebounce } from 'use-debounce';
import { cn } from '@/lib/helper'; import { cn } from '@/lib/helper';
import FieldMessage from '@/components/helper/FieldMessage';
export interface OptionType { export interface OptionType {
value: string | number; value: string | number;
@@ -98,10 +93,7 @@ const SelectInput = <T extends OptionType>(props: SelectInputProps<T>) => {
return { ...base, IndicatorSeparator: () => null }; return { ...base, IndicatorSeparator: () => null };
}, [isAnimated]); }, [isAnimated]);
const internalInputChangeHandler = ( const internalInputChangeHandler = (val: string, meta: InputActionMeta) => {
val: string,
meta: InputActionMeta
) => {
if (meta.action === 'input-change') setInternalInputValue(val); if (meta.action === 'input-change') setInternalInputValue(val);
if (meta.action === 'menu-close') setInternalInputValue(''); if (meta.action === 'menu-close') setInternalInputValue('');
}; };
@@ -113,9 +105,7 @@ const SelectInput = <T extends OptionType>(props: SelectInputProps<T>) => {
const SelectComponent = createables ? CreatableSelect : Select; const SelectComponent = createables ? CreatableSelect : Select;
/** 🎯 handleChange tanpa any */ /** 🎯 handleChange tanpa any */
const handleChange = ( const handleChange = (val: MultiValue<T> | SingleValue<T>): void => {
val: MultiValue<T> | SingleValue<T>
): void => {
if (!val) { if (!val) {
onChange?.(null); onChange?.(null);
return; return;
@@ -128,6 +118,9 @@ const SelectInput = <T extends OptionType>(props: SelectInputProps<T>) => {
} }
}; };
const showErrorMessage = Boolean(isError && errorMessage);
const feedbackMessage = showErrorMessage ? errorMessage : bottomLabel;
return ( return (
<div <div
className={cn( className={cn(
@@ -145,15 +138,15 @@ const SelectInput = <T extends OptionType>(props: SelectInputProps<T>) => {
> >
{label} {label}
{required && ( {required && (
<span className="tooltip tooltip-error" data-tip="required"> <span className='tooltip tooltip-error' data-tip='required'>
<span className="text-error"> *</span> <span className='text-error'> *</span>
</span> </span>
)} )}
</span> </span>
)} )}
<SelectComponent<T, boolean, GroupBase<T>> <SelectComponent<T, boolean, GroupBase<T>>
instanceId="select" instanceId='select'
value={value ?? (isMulti ? [] : null)} value={value ?? (isMulti ? [] : null)}
onChange={handleChange} onChange={handleChange}
options={options} 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>} <FieldMessage
{!isError && bottomLabel && ( message={feedbackMessage}
<p className="w-full text-sm opacity-60">{bottomLabel}</p> tone={showErrorMessage ? 'error' : 'info'}
)} isVisible={showErrorMessage || Boolean(bottomLabel)}
/>
</div> </div>
); );
}; };
+9 -5
View File
@@ -2,6 +2,7 @@
import React, { useState, KeyboardEvent, ChangeEvent, useEffect } from 'react'; import React, { useState, KeyboardEvent, ChangeEvent, useEffect } from 'react';
import { cn } from '@/lib/helper'; import { cn } from '@/lib/helper';
import FieldMessage from '@/components/helper/FieldMessage';
export interface TagInputProps { export interface TagInputProps {
label?: string; label?: string;
@@ -73,6 +74,9 @@ const TagInput: React.FC<TagInputProps> = ({
setInputValue(e.target.value); setInputValue(e.target.value);
}; };
const showErrorMessage = Boolean(isError && errorMessage);
const feedbackMessage = showErrorMessage ? errorMessage : bottomLabel;
return ( return (
<div <div
className={cn( className={cn(
@@ -157,11 +161,11 @@ const TagInput: React.FC<TagInputProps> = ({
)} )}
</div> </div>
{/* Bottom label or error message */} <FieldMessage
{!isError && bottomLabel && ( message={feedbackMessage}
<p className='w-full text-sm opacity-60'>{bottomLabel}</p> tone={showErrorMessage ? 'error' : 'info'}
)} isVisible={showErrorMessage || Boolean(bottomLabel)}
{isError && <p className='w-full text-sm text-error'>{errorMessage}</p>} />
</div> </div>
); );
}; };
+37 -36
View File
@@ -1,12 +1,9 @@
'use client'; 'use client';
import { import { ChangeEventHandler, FocusEventHandler, ReactNode } from 'react';
ChangeEventHandler,
FocusEventHandler,
ReactNode,
} from 'react';
import { cn } from '@/lib/helper'; import { cn } from '@/lib/helper';
import FieldMessage from '@/components/helper/FieldMessage';
export interface TextAreaProps { export interface TextAreaProps {
label?: string; label?: string;
@@ -52,8 +49,11 @@ const TextArea = ({
onBlur, onBlur,
readOnly = false, readOnly = false,
isLoading = false, isLoading = false,
rows = 3 rows = 3,
}: TextAreaProps) => { }: TextAreaProps) => {
const showErrorMessage = Boolean(isError && errorMessage);
const feedbackMessage = showErrorMessage ? errorMessage : bottomLabel;
return ( return (
<div <div
className={cn( className={cn(
@@ -83,40 +83,41 @@ const TextArea = ({
)} )}
</label> </label>
)} )}
{startAdornment && startAdornment} {startAdornment && startAdornment}
<textarea <textarea
className={cn( className={cn(
'input h-auto px-4 py-2 text-base font-normal leading-6 w-full rounded-lg! outline-none! transition-all', '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-error': isError,
'border-success!': isValid, 'border-success!': isValid,
}, },
className?.inputWrapper 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>
)} )}
id={name}
name={name}
placeholder={placeholder}
value={value}
rows={rows}
onChange={onChange}
onBlur={onBlur}
disabled={disabled}
readOnly={readOnly}
/>
{!isError && bottomLabel && ( {(isLoading || endAdornment) && (
<p className='w-full text-sm opacity-60'>{bottomLabel}</p> <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> </div>
); );
}; };
+9 -6
View File
@@ -8,6 +8,7 @@ import {
} from 'react'; } from 'react';
import { cn } from '@/lib/helper'; import { cn } from '@/lib/helper';
import FieldMessage from '@/components/helper/FieldMessage';
export interface TextInputProps { export interface TextInputProps {
type?: HTMLInputTypeAttribute; type?: HTMLInputTypeAttribute;
@@ -55,6 +56,9 @@ const TextInput = ({
readOnly = false, readOnly = false,
isLoading = false, isLoading = false,
}: TextInputProps) => { }: TextInputProps) => {
const showErrorMessage = Boolean(isError && errorMessage);
const feedbackMessage = showErrorMessage ? errorMessage : bottomLabel;
return ( return (
<div <div
className={cn( className={cn(
@@ -119,12 +123,11 @@ const TextInput = ({
)} )}
</div> </div>
{!isError && bottomLabel && ( <FieldMessage
<p className='w-full text-sm opacity-60'>{bottomLabel}</p> message={feedbackMessage}
)} tone={showErrorMessage ? 'error' : 'info'}
{isError && errorMessage && ( isVisible={showErrorMessage || Boolean(bottomLabel)}
<p className='w-full text-sm text-error'>{errorMessage}</p> />
)}
</div> </div>
); );
}; };