diff --git a/src/components/helper/FieldMessage.tsx b/src/components/helper/FieldMessage.tsx new file mode 100644 index 00000000..e43d0a63 --- /dev/null +++ b/src/components/helper/FieldMessage.tsx @@ -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 = { + 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 ( +
+ + {visible || persistent ? (message ?? '\u00A0') : message} + +
+ ); +}; + +export default FieldMessage; diff --git a/src/components/input/FileInput.tsx b/src/components/input/FileInput.tsx index 6218970c..285a3f42 100644 --- a/src/components/input/FileInput.tsx +++ b/src/components/input/FileInput.tsx @@ -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 (
- {bottomLabel && ( -

{bottomLabel}

- )} - - {isError &&

{errorMessage}

} +
); }; diff --git a/src/components/input/NumberInput.tsx b/src/components/input/NumberInput.tsx index a9b8d9a0..e5319e5b 100644 --- a/src/components/input/NumberInput.tsx +++ b/src/components/input/NumberInput.tsx @@ -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(''); // 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 (
- {!isError && bottomLabel && ( -

{bottomLabel}

- )} - {isError && errorMessage && ( -

{errorMessage}

- )} +
); }; diff --git a/src/components/input/RadioInput.tsx b/src/components/input/RadioInput.tsx index 71a731aa..65589258 100644 --- a/src/components/input/RadioInput.tsx +++ b/src/components/input/RadioInput.tsx @@ -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 (
{/* Label atas */} @@ -97,15 +100,11 @@ const RadioInput = ({ ))}
- {/* Label bawah */} - {!isError && bottomLabel && ( -

{bottomLabel}

- )} - - {/* Pesan error */} - {isError && errorMessage && ( -

{errorMessage}

- )} + ); }; diff --git a/src/components/input/SelectInput.tsx b/src/components/input/SelectInput.tsx index 43a3f622..9caf2e17 100644 --- a/src/components/input/SelectInput.tsx +++ b/src/components/input/SelectInput.tsx @@ -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 = (props: SelectInputProps) => { 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 = (props: SelectInputProps) => { const SelectComponent = createables ? CreatableSelect : Select; /** 🎯 handleChange tanpa any */ - const handleChange = ( - val: MultiValue | SingleValue - ): void => { + const handleChange = (val: MultiValue | SingleValue): void => { if (!val) { onChange?.(null); return; @@ -128,6 +118,9 @@ const SelectInput = (props: SelectInputProps) => { } }; + const showErrorMessage = Boolean(isError && errorMessage); + const feedbackMessage = showErrorMessage ? errorMessage : bottomLabel; + return (
(props: SelectInputProps) => { > {label} {required && ( - - * + + * )} )} > - instanceId="select" + instanceId='select' value={value ?? (isMulti ? [] : null)} onChange={handleChange} options={options} @@ -225,10 +218,11 @@ const SelectInput = (props: SelectInputProps) => { }} /> - {isError &&

{errorMessage}

} - {!isError && bottomLabel && ( -

{bottomLabel}

- )} +
); }; diff --git a/src/components/input/TagInput.tsx b/src/components/input/TagInput.tsx index a14b2f63..11407ada 100644 --- a/src/components/input/TagInput.tsx +++ b/src/components/input/TagInput.tsx @@ -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 = ({ setInputValue(e.target.value); }; + const showErrorMessage = Boolean(isError && errorMessage); + const feedbackMessage = showErrorMessage ? errorMessage : bottomLabel; + return (
= ({ )}
- {/* Bottom label or error message */} - {!isError && bottomLabel && ( -

{bottomLabel}

- )} - {isError &&

{errorMessage}

} + ); }; diff --git a/src/components/input/TextArea.tsx b/src/components/input/TextArea.tsx index e9517277..802e469c 100644 --- a/src/components/input/TextArea.tsx +++ b/src/components/input/TextArea.tsx @@ -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 (
)} - {startAdornment && startAdornment} + {startAdornment && startAdornment} -