fcsc_ipi_frontend/ipi-survey-platform/src/components/common/FormControls.jsx
2026-06-12 11:18:30 +05:30

832 lines
25 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React from 'react';
const caretDownActiveSrc = '/assets/images/caretdown-active.svg';
const calendarIconSrc = '/assets/images/uil_calender.svg';
const baseInputStyles = {
default: {
height: '40px',
border: '2px solid #CBA344',
borderRadius: '4px',
paddingLeft: '16px',
paddingRight: '16px',
transition: 'border-color 0.2s ease, color 0.2s ease',
backgroundColor: '#FFFFFF',
},
toolbar: {
height: '40px',
border: '1px solid #C3C6CB',
borderRadius: '8px',
paddingLeft: '16px',
paddingRight: '16px',
transition: 'border-color 0.2s ease, box-shadow 0.2s ease',
backgroundColor: '#FFFFFF',
color: '#5F646D',
},
};
const focusBorderColors = {
default: '#92722A',
toolbar: '#92722A',
};
const idleBorderColors = {
default: '#CBA344',
toolbar: '#C3C6CB',
};
// export const TextField = ({
// label,
// placeholder,
// value,
// onChange,
// type = 'text',
// width = 'auto', // 'auto' | number | string
// className = '',
// style = {},
// name,
// id,
// leftIcon,
// onFocus,
// onBlur,
// variant = 'default',
// required = false,
// showToggle = false,
// toggleLabels = { show: 'Show', hide: 'Hide' },
// error = '',
// readOnly = false,
// }) => {
// const wrapperStyle = {};
// if (width !== 'auto') {
// wrapperStyle.width = typeof width === 'number' ? `${width}px` : width;
// }
// const [focused, setFocused] = React.useState(false);
// const isPasswordField = type === 'password';
// const [showPassword, setShowPassword] = React.useState(false);
// React.useEffect(() => {
// if (!isPasswordField) {
// setShowPassword(false);
// }
// }, [isPasswordField]);
// const handleFocus = (event) => {
// setFocused(true);
// onFocus?.(event);
// };
// const handleBlur = (event) => {
// setFocused(false);
// onBlur?.(event);
// };
// const baseStyle = baseInputStyles[variant] || baseInputStyles.default;
// const inputStyle = { ...baseStyle, ...style };
// if (leftIcon) {
// inputStyle.paddingLeft = '40px';
// }
// if (isPasswordField && showToggle) {
// inputStyle.paddingRight = '44px';
// }
// const hasError = Boolean(error);
// inputStyle.borderColor = hasError
// ? '#B91C1C'
// : focused && !readOnly
// ? focusBorderColors[variant]
// : idleBorderColors[variant] || idleBorderColors.default;
// if (hasError) {
// inputStyle.backgroundColor = '#FEF2F2';
// inputStyle.color = '#B91C1C';
// }
// if (!hasError && variant !== 'toolbar') {
// inputStyle.color = '#232528';
// }
// if (readOnly) {
// inputStyle.backgroundColor = '#E6D7A2';
// inputStyle.cursor = 'not-allowed';
// inputStyle.opacity = '0.7';
// }
// if (!hasError && variant === 'toolbar') {
// inputStyle.color = '#5F646D';
// }
// return (
// <div style={wrapperStyle} className="w-full">
// {label && (
// <label htmlFor={id || name} className="block text-[14px] leading-[20px] font-medium text-[#232528] mb-1">
// {label}
// {required && <span className="text-[#B91C1C] ml-1">*</span>}
// </label>
// )}
// <div className="relative">
// <input
// id={id || name}
// name={name}
// type={isPasswordField ? (showPassword ? 'text' : 'password') : type}
// value={value}
// onChange={onChange}
// placeholder={placeholder}
// className={`w-full text-sm focus:outline-none ${className}`}
// style={inputStyle}
// onFocus={handleFocus}
// onBlur={handleBlur}
// required={required}
// readOnly={readOnly}
// />
// {leftIcon && (
// <img src={leftIcon} alt="" className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4" />
// )}
// {isPasswordField && showToggle && (
// <button
// type="button"
// onClick={() => setShowPassword((prev) => !prev)}
// className="absolute right-3 top-1/2 -translate-y-1/2 text-[#92722A] hover:text-[#7b5c1f]"
// aria-label={showPassword ? toggleLabels.hide : toggleLabels.show}
// title={showPassword ? toggleLabels.hide : toggleLabels.show}
// >
// {showPassword ? (
// <svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
// <path strokeLinecap="round" strokeLinejoin="round" d="M3 3l18 18" />
// <path strokeLinecap="round" strokeLinejoin="round" d="M10.58 10.58A2 2 0 0012 14a2 2 0 001.42-.58M16.68 16.68C15.28 17.54 13.69 18 12 18 7 18 3.73 14.82 2 12c.58-.9 1.34-1.83 2.26-2.68M9.88 4.13C10.56 4.05 11.27 4 12 4c5 0 8.27 3.18 10 6-.46.72-1.03 1.43-1.71 2.1" />
// </svg>
// ) : (
// <svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
// <path strokeLinecap="round" strokeLinejoin="round" d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8S1 12 1 12z" />
// <circle cx="12" cy="12" r="3" />
// </svg>
// )}
// </button>
// )}
// </div>
// {error && <p className="mt-1 text-xs text-[#B91C1C]">{error}</p>}
// </div>
// );
// };
export const TextField = ({
label,
placeholder,
value,
onChange,
type = 'text',
width = 'auto',
className = '',
style = {},
name,
id,
leftIcon,
onFocus,
onBlur,
variant = 'default',
required = false,
showToggle = false,
toggleLabels = { show: 'Show', hide: 'Hide' },
error = '',
readOnly = false,
}) => {
const wrapperStyle = {};
if (width !== 'auto') {
wrapperStyle.width = typeof width === 'number' ? `${width}px` : width;
}
const [focused, setFocused] = React.useState(false);
const isPasswordField = type === 'password';
const [showPassword, setShowPassword] = React.useState(false);
React.useEffect(() => {
if (!isPasswordField) setShowPassword(false);
}, [isPasswordField]);
const handleFocus = (event) => {
if (!readOnly) setFocused(true);
onFocus?.(event);
};
const handleBlur = (event) => {
setFocused(false);
onBlur?.(event);
};
// Base styles
const baseStyle = baseInputStyles[variant] || baseInputStyles.default;
const inputStyle = { ...baseStyle, ...style };
if (leftIcon) inputStyle.paddingLeft = '40px';
if (isPasswordField && showToggle) inputStyle.paddingRight = '44px';
// Error, focus, idle, readOnly colors
const hasError = Boolean(error);
if (hasError) {
inputStyle.borderColor = '#B91C1C'; // red border
inputStyle.backgroundColor = '#FEF2F2';
inputStyle.color = '#B91C1C';
} else if (readOnly) {
inputStyle.borderColor = '#CBA344'; // standard
inputStyle.outline = 'none';
inputStyle.backgroundColor = '#FFFFFF'; // ✅ disabled color
inputStyle.cursor = 'not-allowed';
inputStyle.opacity = '0.8';
} else if (focused) {
inputStyle.borderColor = '#92722A'; // ✅ focused color
inputStyle.outline = 'none';
inputStyle.backgroundColor = '#FFFFFF';
} else {
inputStyle.borderColor = '#CBA344'; // ✅ standard color
inputStyle.outline = 'none';
inputStyle.backgroundColor = '#FFFFFF';
}
inputStyle.color = hasError ? '#B91C1C' : '#232528';
return (
<div style={wrapperStyle} className="w-full">
{label && (
<label htmlFor={id || name} className="block text-[14px] leading-[20px] font-medium text-[#232528] mb-1">
{label}
{required && <span className="text-[#B91C1C] ml-1">*</span>}
</label>
)}
<div className="relative">
<input
id={id || name}
name={name}
type={isPasswordField ? (showPassword ? 'text' : 'password') : type}
value={value}
onChange={onChange}
placeholder={placeholder}
className={`w-full text-sm focus:outline-none ${className}`}
style={inputStyle}
onFocus={handleFocus}
onBlur={handleBlur}
required={required}
readOnly={readOnly}
/>
{leftIcon && (
<img
src={leftIcon}
alt=""
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4"
/>
)}
{isPasswordField && showToggle && (
<button
type="button"
onClick={() => setShowPassword((prev) => !prev)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-[#92722A] hover:text-[#7b5c1f]"
aria-label={showPassword ? toggleLabels.hide : toggleLabels.show}
title={showPassword ? toggleLabels.hide : toggleLabels.show}
>
{showPassword ? (
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path strokeLinecap="round" strokeLinejoin="round" d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8S1 12 1 12z" />
<circle cx="12" cy="12" r="3" />
</svg>
) : (
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path strokeLinecap="round" strokeLinejoin="round" d="M3 3l18 18" />
<path strokeLinecap="round" strokeLinejoin="round" d="M10.58 10.58A2 2 0 0012 14a2 2 0 001.42-.58M16.68 16.68C15.28 17.54 13.69 18 12 18 7 18 3.73 14.82 2 12c.58-.9 1.34-1.83 2.26-2.68M9.88 4.13C10.56 4.05 11.27 4 12 4c5 0 8.27 3.18 10 6-.46.72-1.03 1.43-1.71 2.1" />
</svg>
)}
</button>
)}
</div>
{error && <p className="mt-1 text-xs text-[#B91C1C]">{error}</p>}
</div>
);
};
export const SelectField = ({
label,
value,
onChange,
options = [],
width = 'auto',
className = '',
style = {},
name,
id,
rightIcon = caretDownActiveSrc,
placeholder,
onFocus,
onBlur,
variant = '#92722A',
allowClear = false,
onClear,
clearValue = '',
error = '',
readOnly = false,
required = false,
forceDropdownBelow = false,
}) => {
const wrapperStyle = {};
if (width !== 'auto') {
wrapperStyle.width = typeof width === 'number' ? `${width}px` : width;
}
const baseStyle = baseInputStyles[variant] || baseInputStyles.default;
const inputStyle = { ...baseStyle, ...style };
const [focused, setFocused] = React.useState(false);
const [isOpen, setIsOpen] = React.useState(false);
const [searchTerm, setSearchTerm] = React.useState('');
const dropdownRef = React.useRef(null);
const inputRef = React.useRef(null);
const normalizedClear = clearValue ?? '';
const hasValue = value !== undefined && value !== null && value !== '' && value !== normalizedClear;
const showClear = allowClear && hasValue && !readOnly;
if (rightIcon) {
inputStyle.paddingRight = '40px';
}
// Filter options based on search term
const filteredOptions = React.useMemo(() => {
if (!searchTerm) return options;
return options.filter(opt =>
opt.label.toLowerCase().includes(searchTerm.toLowerCase())
);
}, [options, searchTerm]);
// Close dropdown when clicking outside
React.useEffect(() => {
const handleClickOutside = (event) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target)) {
setIsOpen(false);
setSearchTerm('');
}
};
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isOpen]);
const handleFocus = (event) => {
setFocused(true);
onFocus?.(event);
};
const handleBlur = (event) => {
setFocused(false);
onBlur?.(event);
};
const handleClear = (event) => {
event.preventDefault();
event.stopPropagation();
if (onClear) {
onClear();
} else if (onChange) {
onChange({ target: { value: normalizedClear } });
}
};
const handleSelect = (selectedValue) => {
if (!readOnly) {
onChange({ target: { value: selectedValue } });
setIsOpen(false);
setSearchTerm('');
}
};
const toggleDropdown = () => {
if (!readOnly) {
setIsOpen(!isOpen);
setSearchTerm('');
}
};
const getDisplayValue = () => {
const selected = options.find(opt => opt.value === value);
return selected ? selected.label : (placeholder || '');
};
const hasError = Boolean(error);
inputStyle.borderColor = hasError
? '#B91C1C'
: focused
? focusBorderColors[variant]
: idleBorderColors[variant] || idleBorderColors.default;
if (hasError) {
inputStyle.backgroundColor = '#E6D7A2';
inputStyle.color = '#B91C1C';
} else if (variant === 'toolbar') {
inputStyle.color = hasValue && value !== 'All' && value !== 'Status' && value !== 'Year' && value !== 'Quarter' && value !== 'Emirates' ? '#232528' : '#5F646D';
} else {
inputStyle.color = hasValue ? '#232528' : '#232528';
}
if (readOnly) {
inputStyle.backgroundColor = '#F9FAFB';
inputStyle.cursor = 'not-allowed';
}
// Custom dropdown implementation
if (forceDropdownBelow) {
return (
<div className={`w-full ${wrapperStyle ? '' : ''}`} style={wrapperStyle || {}} ref={dropdownRef}>
{label && (
<label htmlFor={id || name} className="block text-sm font-medium text-gray-900 mb-1">
{label}
{required && <span className="text-red-700 ml-1">*</span>}
</label>
)}
<div className="relative w-full">
<div
className={`w-full text-sm focus:outline-none ${className}`}
style={{
...inputStyle,
height: 'auto', // override fixed height
minHeight: '40px',
cursor: readOnly ? 'not-allowed' : 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '8px 16px',
}}
onClick={toggleDropdown}
onFocus={handleFocus}
onBlur={handleBlur}
tabIndex={readOnly ? -1 : 0}
>
<span
style={{
color: hasValue ? '#232528' : '#9CA3AF',
flex: 1,
whiteSpace: 'normal',
lineHeight: '20px'
}}
>
{getDisplayValue()}
</span>
{showClear ? (
<button
type="button"
className="h-5 w-5 rounded-full text-[#9CA3AF] hover:text-[#4B5563]"
onClick={handleClear}
aria-label="Clear selection"
>
×
</button>
) : (
rightIcon && <img src={rightIcon} alt="" className="h-4 w-4" />
)}
</div>
{isOpen && !readOnly && (
<div
className="absolute z-50 w-full mt-1 bg-white border border-[#CBA344] rounded-md shadow-lg max-h-60 overflow-auto"
style={{
top: '100%',
left: 0,
right: 0
}}
>
{/* Search input */}
<div className="p-2 border-b border-gray-200">
<input
type="text"
placeholder="Search..."
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-[#92722A] focus:border-transparent"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
onClick={(e) => e.stopPropagation()}
/>
</div>
{/* Options list */}
{filteredOptions.length === 0 ? (
<div className="px-3 py-2 text-gray-500 text-sm">No options found</div>
) : (
filteredOptions.map((opt) => (
<div
key={opt.value}
className={`px-3 py-2 cursor-pointer text-sm hover:bg-[#F9FAFB] ${
opt.value === value ? 'bg-[#E6D7A2] text-[#232528]' : 'text-[#232528]'
}`}
onClick={() => handleSelect(opt.value)}
>
{opt.label}
</div>
))
)}
</div>
)}
</div>
{readOnly && <div className="absolute inset-0 pointer-events-none" />}
{error && <p className="mt-1 text-xs text-[#B91C1C]">{error}</p>}
</div>
);
}
// Original native select implementation
return (
<div className={`w-full ${wrapperStyle ? '' : ''}`} style={wrapperStyle || {}}>
{label && (
<label htmlFor={id || name} className="block text-sm font-medium text-gray-900 mb-1">
{label}
{required && <span className="text-red-700 ml-1">*</span>}
</label>
)}
<div className="relative w-full">
<select
id={id || name}
name={name}
value={value}
onChange={(event) => {
if (!readOnly) {
onChange(event);
}
}}
className={`w-full appearance-none text-sm focus:outline-none ${className}`}
style={inputStyle}
onFocus={handleFocus}
onBlur={handleBlur}
required={required}
>
{placeholder && (
<option value="" disabled hidden>
{placeholder}
</option>
)}
{options.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
{showClear ? (
<button
type="button"
className="absolute right-3 top-1/2 -translate-y-1/2 h-5 w-5 rounded-full text-[#9CA3AF] hover:text-[#4B5563]"
onClick={handleClear}
aria-label="Clear selection"
>
×
</button>
) : (
rightIcon && <img src={rightIcon} alt="" className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4" />
)}
</div>
{readOnly && <div className="absolute inset-0 pointer-events-none" />}
{error && <p className="mt-1 text-xs text-[#B91C1C]">{error}</p>}
</div>
);
};
const formatPhoneDigits = (digits) => {
if (!digits) return '';
const cleaned = digits.replace(/\D/g, '').slice(0, 8);
const part1 = cleaned.slice(0, 2);
const part2 = cleaned.slice(2, 5);
const part3 = cleaned.slice(5, 8);
return [part1, part2, part3].filter(Boolean).join(' ');
};
export const PhoneField = ({
label,
countryCode = '+971',
onCountryCodeChange,
countryOptions = [{ label: '+971', value: '+971' }],
placeholder = 'XX XXX XXX',
value,
onChange,
width = 'auto',
required = false,
error = '',
readOnly = false,
className = '',
}) => {
const wrapperStyle = {};
if (width !== 'auto') {
wrapperStyle.width = typeof width === 'number' ? `${width}px` : width;
}
const [focused, setFocused] = React.useState(false);
const handleFocus = () => {
if (!readOnly) setFocused(true);
};
const handleBlur = () => {
setFocused(false);
};
const hasError = Boolean(error);
const borderColor = hasError ? '#B91C1C' : focused ? '#92722A' : '#CBA344';
const containerStyle = {
display: 'flex',
alignItems: 'center',
border: `2px solid ${borderColor}`,
borderRadius: '4px',
backgroundColor: readOnly ? '#F9FAFB' : hasError ? '#FEF2F2' : '#FFFFFF',
transition: 'border-color 0.2s ease',
height: '40px',
paddingRight: '8px',
pointerEvents: readOnly ? 'none' : 'auto',
};
const selectStyle = {
border: 'none',
background: 'transparent',
paddingLeft: '12px',
paddingRight: '24px',
fontSize: '14px',
color: hasError ? '#B91C1C' : '#232528',
appearance: 'none',
WebkitAppearance: 'none',
MozAppearance: 'none',
cursor: readOnly ? 'not-allowed' : 'pointer',
height: '100%',
};
const inputStyle = {
flex: 1,
border: 'none',
outline: 'none',
background: 'transparent',
fontSize: '14px',
color: hasError ? '#B91C1C' : '#232528',
padding: '0 0 0 8px',
};
const arrowStyle = {
position: 'absolute',
right: '8px',
top: '50%',
transform: 'translateY(-50%)',
pointerEvents: 'none',
height: '12px',
width: '12px',
};
const handleSelectChange = (event) => {
onCountryCodeChange?.(event.target.value);
};
const handleInputChange = (event) => {
const digits = event.target.value.replace(/\D/g, '') || '';
const formatted = formatPhoneDigits(digits);
onChange?.(formatted);
};
const displayValue = formatPhoneDigits((value || '').replace(/\D/g, ''));
return (
<div style={wrapperStyle} className="w-full">
{label && (
<label className="block text-[14px] leading-[20px] font-medium text-[#232528] mb-1">
{label}
{required && <span className="text-[#B91C1C] ml-1">*</span>}
</label>
)}
<div style={containerStyle} className={`relative ${className}`} onFocus={handleFocus} onBlur={handleBlur}>
<div className="relative h-full flex items-center">
<select value={countryCode} onChange={handleSelectChange} style={selectStyle} disabled={readOnly}>
{countryOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
<img src={caretDownActiveSrc} alt="" style={arrowStyle} />
</div>
<input
type="text"
value={displayValue}
onChange={handleInputChange}
placeholder={placeholder}
style={inputStyle}
disabled={readOnly}
/>
</div>
{error && <p className="mt-1 text-xs text-[#B91C1C]">{error}</p>}
</div>
);
};
export const DateField = ({
label,
value,
onChange,
placeholder,
width = 'auto',
className = '',
style = {},
name,
id,
rightIcon = calendarIconSrc,
onBlur,
onFocus,
required=false,
...rest
}) => {
const inputRef = React.useRef(null);
const [isDateMode, setIsDateMode] = React.useState(Boolean(value));
const [focused, setFocused] = React.useState(false);
React.useEffect(() => {
if (typeof document === 'undefined') return;
if (document.getElementById('date-field-style')) return;
const styleElement = document.createElement('style');
styleElement.id = 'date-field-style';
styleElement.innerHTML = `
input.date-field::-webkit-calendar-picker-indicator {
opacity: 0;
position: absolute;
right: 0;
width: 40px;
height: 100%;
cursor: pointer;
}
input.date-field::-webkit-clear-button { display: none; }
input.date-field::-webkit-inner-spin-button { display: none; }
input.date-field { -webkit-appearance: none; }
input.date-field::placeholder { color: #9CA3AF; }
`;
document.head.appendChild(styleElement);
}, []);
const wrapperStyle = {};
if (width !== 'auto') {
wrapperStyle.width = typeof width === 'number' ? `${width}px` : width;
}
// Format the minDate prop if provided
const formatDateForInput = (dateString) => {
if (!dateString) return '';
const date = new Date(dateString);
return date.toISOString().split('T')[0];
};
const inputStyle = {
...(baseInputStyles?.default || {}),
...style,
color: value ? '#232528' : '#232528',
borderColor: focused ? '#92722A' : '#CBA344',
};
if (rightIcon) {
inputStyle.paddingRight = '40px';
}
React.useEffect(() => {
setIsDateMode(Boolean(value));
}, [value]);
const triggerPicker = () => {
setIsDateMode(true);
requestAnimationFrame(() => {
if (inputRef.current) {
inputRef.current.showPicker?.();
inputRef.current.focus?.();
}
});
};
const handleFocus = (event) => {
onFocus?.(event);
setFocused(true);
if (!isDateMode) {
triggerPicker();
}
};
const handleBlur = (event) => {
onBlur?.(event);
setFocused(false);
if (!value) {
setIsDateMode(false);
}
};
return (
<div style={wrapperStyle} className="w-full">
{label && (
<label htmlFor={id || name} className="block text-[14px] leading-[20px] font-medium text-[#232528] mb-1">
{label}
{required && <span className="text-[#B91C1C] ml-1">*</span>}
</label>
)}
<div className="relative">
<input
ref={inputRef}
id={id || name}
name={name}
type={isDateMode ? 'date' : 'text'}
value={value}
onChange={onChange}
placeholder={placeholder}
className={`date-field w-full text-sm focus:outline-none ${className}`}
style={inputStyle}
onFocus={handleFocus}
onBlur={handleBlur}
min={formatDateForInput(rest.minDate)}
required={required}
{...rest}
/>
{rightIcon && (
<button
type="button"
className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 grid place-items-center text-transparent"
onClick={triggerPicker}
tabIndex={-1}
aria-hidden="true"
>
<img src={rightIcon} alt="" className="pointer-events-none h-4 w-4" />
</button>
)}
</div>
</div>
);
};