mirror of
https://gitlab.com/mbugroup/lti-web-client.git
synced 2026-05-20 21:41:57 +00:00
feat(FE-Storyless): add FieldMessage component for consistent field feedback across inputs
This commit is contained in:
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user