martinms

Untitled

Nov 5th, 2025
592
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
PHP 8.93 KB | None | 0 0
  1. import { useEffect, useMemo, useState } from 'react';
  2. import Select2, {
  3.     CSSObjectWithLabel,
  4.     GroupBase,
  5.     SingleValue,
  6.     StylesConfig,
  7. } from 'react-select';
  8.  
  9. export interface SelectOption {
  10.     value: string;
  11.     label: string;
  12.     isDefault?: boolean;
  13. }
  14.  
  15. export interface SelectProps {
  16.     id?: string;
  17.     name: string;
  18.     options: SelectOption[];
  19.     defaultValue?: string;
  20.     placeholder?: string;
  21.     className?: string;
  22.     errorMessage?: string;
  23.     hint?: string;
  24.     success?: boolean;
  25.     isDisabled?: boolean;
  26.     resetKey?: number;
  27.     required?: boolean;
  28.     /** Tampilkan tombol clear (Γ—) untuk mengosongkan pilihan */
  29.     allowClear?: boolean; // <β€” baru
  30.     onChange?: (option: SelectOption | null) => void;
  31. }
  32.  
  33. export default function ReactSelect2({
  34.     id,
  35.     name,
  36.     options,
  37.     defaultValue,
  38.     placeholder = 'Pilih…',
  39.     className,
  40.     errorMessage = '',
  41.     hint,
  42.     success = false,
  43.     isDisabled,
  44.     resetKey,
  45.     required = false,
  46.     allowClear = false, // <β€” default
  47.     onChange,
  48. }: SelectProps) {
  49.     const error = errorMessage !== '';
  50.     const displayHint = error ? errorMessage : hint;
  51.  
  52.     // Prioritas: 1) defaultValue 2) opsi pertama isDefault 3) null
  53.     const defaultSelected = useMemo(() => {
  54.         if (defaultValue) {
  55.             const byValue = options.find((opt) => opt.value === defaultValue);
  56.             if (byValue) return byValue;
  57.         }
  58.         const defaults = options.filter((opt) => opt.isDefault);
  59.         if (defaults.length > 0) return defaults[0];
  60.         return null;
  61.     }, [defaultValue, options]);
  62.  
  63.     useEffect(() => {
  64.         const countDefault = options.reduce(
  65.             (acc, o) => acc + (o.isDefault ? 1 : 0),
  66.             0,
  67.         );
  68.         if (!defaultValue && countDefault > 1) {
  69.             console.warn(
  70.                 `[ReactSelect2:${name}] Terdapat ${countDefault} opsi dengan isDefault=true. ` +
  71.                     `Yang dipakai hanya yang pertama: "${
  72.                        options.find((o) => o.isDefault)?.label
  73.                    }".`,
  74.             );
  75.         }
  76.     }, [options, defaultValue, name]);
  77.  
  78.     const [selected, setSelected] = useState<SelectOption | null>(
  79.         defaultSelected,
  80.     );
  81.  
  82.     useEffect(() => {
  83.         setSelected(defaultSelected);
  84.     }, [defaultSelected]);
  85.  
  86.     useEffect(() => {
  87.         if (resetKey && resetKey > 0) setSelected(null);
  88.     }, [resetKey]);
  89.  
  90.     const handleChange = (value: SingleValue<SelectOption>) => {
  91.         setSelected(value);
  92.         if (typeof onChange === 'function') onChange(value ?? null);
  93.     };
  94.  
  95.     // Catatan SSR: idealnya ambil tema dari context/prop.
  96.     const isDark =
  97.         typeof window !== 'undefined' &&
  98.         document.documentElement.classList.contains('dark');
  99.  
  100.     const customStyles: StylesConfig<
  101.         SelectOption,
  102.         false,
  103.         GroupBase<SelectOption>
  104.     > = {
  105.         control: (base: CSSObjectWithLabel): CSSObjectWithLabel => ({
  106.             ...base,
  107.             minHeight: '44px',
  108.             height: '44px',
  109.             backgroundColor: isDisabled
  110.                 ? isDark
  111.                     ? '#1f2937'
  112.                     : '#f3f4f6'
  113.                 : isDark
  114.                   ? '#111827'
  115.                   : '#ffffff',
  116.             borderColor: error
  117.                 ? '#ef4444'
  118.                 : success
  119.                   ? '#10b981'
  120.                   : isDark
  121.                     ? '#374151'
  122.                     : '#d1d5db',
  123.             color: isDark ? '#f3f4f6' : '#000000',
  124.             boxShadow: '0 1px 2px 0 rgba(0, 0, 0, 0.05)',
  125.             borderRadius: '8px',
  126.             borderWidth: '1px',
  127.             padding: '0 16px',
  128.             fontSize: '14px',
  129.             cursor: isDisabled ? 'not-allowed' : 'pointer',
  130.             opacity: isDisabled ? 0.4 : 1,
  131.             ':hover': {
  132.                 borderColor: error
  133.                     ? '#ef4444'
  134.                     : success
  135.                       ? '#10b981'
  136.                       : isDark
  137.                         ? '#4b5563'
  138.                         : '#9ca3af',
  139.             },
  140.             ':focus-within': {
  141.                 borderColor: error
  142.                     ? '#dc2626'
  143.                     : success
  144.                       ? '#059669'
  145.                       : isDark
  146.                         ? '#60a5fa'
  147.                         : '#3b82f6',
  148.                 boxShadow: error
  149.                     ? '0 0 0 3px rgba(239, 68, 68, 0.1)'
  150.                     : success
  151.                       ? '0 0 0 3px rgba(16, 185, 129, 0.1)'
  152.                       : isDark
  153.                         ? '0 0 0 3px rgba(96, 165, 250, 0.1)'
  154.                         : '0 0 0 3px rgba(59, 130, 246, 0.1)',
  155.             },
  156.         }),
  157.         menu: (base) => ({
  158.             ...base,
  159.             backgroundColor: isDark ? '#1f2937' : '#ffffff',
  160.             boxShadow: isDark
  161.                 ? '0 20px 25px -5px rgba(0, 0, 0, 0.5)'
  162.                 : '0 20px 25px -5px rgba(0, 0, 0, 0.1)',
  163.             borderRadius: '8px',
  164.             borderWidth: '1px',
  165.             borderColor: isDark ? '#374151' : '#e5e7eb',
  166.         }),
  167.         menuList: (base) => ({
  168.             ...base,
  169.             backgroundColor: isDark ? '#1f2937' : '#ffffff',
  170.             padding: '4px 0',
  171.         }),
  172.         option: (base, props) => ({
  173.             ...base,
  174.             backgroundColor: props.isSelected
  175.                 ? error
  176.                     ? '#ef4444'
  177.                     : success
  178.                       ? '#10b981'
  179.                       : '#3b82f6'
  180.                 : props.isFocused
  181.                   ? isDark
  182.                       ? '#374151'
  183.                       : '#f3f4f6'
  184.                   : isDark
  185.                     ? '#1f2937'
  186.                     : '#ffffff',
  187.             color: props.isSelected
  188.                 ? '#ffffff'
  189.                 : isDark
  190.                   ? '#f3f4f6'
  191.                   : '#000000',
  192.             padding: '10px 16px',
  193.             cursor: 'pointer',
  194.             ':active': {
  195.                 backgroundColor: error
  196.                     ? '#dc2626'
  197.                     : success
  198.                       ? '#059669'
  199.                       : '#2563eb',
  200.             },
  201.         }),
  202.         input: (base) => ({
  203.             ...base,
  204.             color: isDark ? '#f3f4f6' : '#000000',
  205.             margin: 0,
  206.             padding: 0,
  207.         }),
  208.         placeholder: (base) => ({
  209.             ...base,
  210.             color: isDark ? '#9ca3af' : '#9ca3af',
  211.         }),
  212.         singleValue: (base) => ({
  213.             ...base,
  214.             color: isDisabled
  215.                 ? isDark
  216.                     ? '#6b7280'
  217.                     : '#9ca3af'
  218.                 : isDark
  219.                   ? '#f3f4f6'
  220.                   : '#000000',
  221.         }),
  222.     };
  223.  
  224.     // Style "visually hidden" agar tetap tervalidasi tapi tak terlihat
  225.     const visuallyHidden: React.CSSProperties = {
  226.         position: 'absolute',
  227.         opacity: 0,
  228.         width: 1,
  229.         height: 1,
  230.         padding: 0,
  231.         margin: 0,
  232.         border: 0,
  233.         clip: 'rect(0 0 0 0)',
  234.         clipPath: 'inset(50%)',
  235.         overflow: 'hidden',
  236.         whiteSpace: 'nowrap',
  237.         pointerEvents: 'none',
  238.     };
  239.  
  240.     const ariaInvalid = error || (required && !selected) ? true : undefined;
  241.  
  242.     return (
  243.         <div className={className} aria-required={required}>
  244.             <div className="relative">
  245.                 <Select2
  246.                     inputId={id}
  247.                     isClearable={allowClear && !isDisabled}
  248.                     isSearchable={true}
  249.                     isDisabled={isDisabled}
  250.                     options={options}
  251.                     value={selected}
  252.                     onChange={handleChange}
  253.                     placeholder={placeholder}
  254.                     classNamePrefix="rs"
  255.                     styles={customStyles}
  256.                     aria-invalid={ariaInvalid}
  257.                 />
  258.  
  259.                 {/* Input yang ikut submit & validasi native (bukan type="hidden") */}
  260.                 <input
  261.                     type="text"
  262.                     name={name}
  263.                     value={selected?.value || ''}
  264.                     readOnly
  265.                     required={required}
  266.                     aria-hidden="true"
  267.                     aria-invalid={ariaInvalid}
  268.                     style={visuallyHidden}
  269.                 />
  270.  
  271.                 {displayHint && (
  272.                     <p
  273.                         className={`mt-1.5 text-xs ${
  274.                             error
  275.                                 ? 'text-error-500 dark:text-error-400'
  276.                                 : success
  277.                                   ? 'text-success-500 dark:text-success-400'
  278.                                   : 'text-gray-500 dark:text-gray-400'
  279.                         }`}
  280.                     >
  281.                         {displayHint}
  282.                     </p>
  283.                 )}
  284.             </div>
  285.         </div>
  286.     );
  287. }
  288.  
Advertisement
Add Comment
Please, Sign In to add comment