Files
ai-toolkit/ui/src/components/formInputs.tsx
2026-03-31 16:54:55 -06:00

443 lines
13 KiB
TypeScript

'use client';
import React, { forwardRef } from 'react';
import classNames from 'classnames';
import dynamic from 'next/dynamic';
import { CircleHelp } from 'lucide-react';
import { getDoc } from '@/docs';
import { openDoc } from '@/components/DocModal';
import { ConfigDoc, GroupedSelectOption, SelectOption } from '@/types';
const Select = dynamic(() => import('react-select'), { ssr: false });
const labelClasses = 'block text-xs mb-1 mt-2 text-gray-300';
const inputClasses =
'w-full text-sm px-3 py-1 bg-gray-800 border border-gray-700 rounded-sm focus:ring-2 focus:ring-gray-600 focus:border-transparent';
export interface InputProps {
label?: string;
docKey?: string | null;
doc?: ConfigDoc | null;
className?: string;
placeholder?: string;
required?: boolean;
}
export interface TextInputProps extends InputProps {
value: string;
onChange: (value: string) => void;
type?: 'text' | 'password';
disabled?: boolean;
}
export const TextInput = forwardRef<HTMLInputElement, TextInputProps>((props: TextInputProps, ref) => {
const { label, value, onChange, placeholder, required, disabled, type = 'text', className, docKey = null } = props;
let { doc } = props;
if (!doc && docKey) {
doc = getDoc(docKey);
}
return (
<div className={classNames(className)}>
{label && (
<label className={labelClasses}>
{label}{' '}
{doc && (
<div className="inline-block ml-1 text-xs text-gray-500 cursor-pointer" onClick={() => openDoc(doc)}>
<CircleHelp className="inline-block w-4 h-4 cursor-pointer" />
</div>
)}
</label>
)}
<input
ref={ref}
type={type}
value={value}
onChange={e => {
if (!disabled) onChange(e.target.value);
}}
className={`${inputClasses} ${disabled ? 'opacity-30 cursor-not-allowed' : ''}`}
placeholder={placeholder}
required={required}
disabled={disabled}
/>
</div>
);
});
// 👇 Helpful for debugging
TextInput.displayName = 'TextInput';
export interface NumberInputProps extends InputProps {
value: number | null;
onChange: (value: number | null) => void;
min?: number;
max?: number;
}
export const NumberInput = (props: NumberInputProps) => {
const { label, value, onChange, placeholder, required, min, max, docKey = null } = props;
let { doc } = props;
if (!doc && docKey) {
doc = getDoc(docKey);
}
// Add controlled internal state to properly handle partial inputs
const [inputValue, setInputValue] = React.useState<string | number>(value ?? '');
// Sync internal state with prop value
React.useEffect(() => {
setInputValue(value ?? '');
}, [value]);
return (
<div className={classNames(props.className)}>
{label && (
<label className={labelClasses}>
{label}{' '}
{doc && (
<div className="inline-block ml-1 text-xs text-gray-500 cursor-pointer" onClick={() => openDoc(doc)}>
<CircleHelp className="inline-block w-4 h-4 cursor-pointer" />
</div>
)}
</label>
)}
<input
type="number"
value={inputValue}
onChange={e => {
const rawValue = e.target.value;
// Update the input display with the raw value
setInputValue(rawValue);
// Handle empty or partial inputs
if (rawValue === '' || rawValue === '-') {
// For empty or partial negative input, don't call onChange yet
return;
}
const numValue = Number(rawValue);
// Only apply constraints and call onChange when we have a valid number
if (!isNaN(numValue)) {
let constrainedValue = numValue;
// Apply min/max constraints if they exist
if (min !== undefined && constrainedValue < min) {
constrainedValue = min;
}
if (max !== undefined && constrainedValue > max) {
constrainedValue = max;
}
onChange(constrainedValue);
}
}}
className={inputClasses}
placeholder={placeholder}
required={required}
min={min}
max={max}
step="any"
/>
</div>
);
};
export interface SelectInputProps extends InputProps {
value: string;
disabled?: boolean;
onChange: (value: string) => void;
options: GroupedSelectOption[] | SelectOption[];
}
export const SelectInput = (props: SelectInputProps) => {
const { label, value, onChange, options, docKey = null } = props;
let { doc } = props;
if (!doc && docKey) {
doc = getDoc(docKey);
}
let selectedOption: SelectOption | undefined;
if (options && options.length > 0) {
// see if grouped options
if ('options' in options[0]) {
selectedOption = (options as GroupedSelectOption[])
.flatMap(group => group.options)
.find(opt => opt.value === value);
} else {
selectedOption = (options as SelectOption[]).find(opt => opt.value === value);
}
}
return (
<div
className={classNames(props.className, {
'opacity-30 cursor-not-allowed': props.disabled,
})}
>
{label && (
<label className={labelClasses}>
{label}{' '}
{doc && (
<div className="inline-block ml-1 text-xs text-gray-500 cursor-pointer" onClick={() => openDoc(doc)}>
<CircleHelp className="inline-block w-4 h-4 cursor-pointer" />
</div>
)}
</label>
)}
<Select
value={selectedOption}
options={options}
isDisabled={props.disabled}
className="aitk-react-select-container"
classNamePrefix="aitk-react-select"
onChange={selected => {
if (selected) {
onChange((selected as { value: string }).value);
}
}}
/>
</div>
);
};
export interface CheckboxProps {
label?: string | React.ReactNode;
checked: boolean;
onChange: (checked: boolean) => void;
className?: string;
required?: boolean;
disabled?: boolean;
docKey?: string | null;
doc?: ConfigDoc | null;
}
export const Checkbox = (props: CheckboxProps) => {
const { label, checked, onChange, required, disabled } = props;
let { doc } = props;
if (!doc && props.docKey) {
doc = getDoc(props.docKey);
}
const id = React.useId();
return (
<div className={classNames('flex items-center gap-3', props.className)}>
<button
type="button"
role="switch"
id={id}
aria-checked={checked}
aria-required={required}
disabled={disabled}
onClick={() => !disabled && onChange(!checked)}
className={classNames(
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-blue-600 focus:ring-offset-2',
checked ? 'bg-blue-500' : 'bg-gray-600',
disabled ? 'opacity-50 cursor-not-allowed' : 'hover:bg-opacity-80',
)}
>
<span className="sr-only">Toggle {label}</span>
<span
className={classNames(
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
checked ? 'translate-x-5' : 'translate-x-0',
)}
/>
</button>
{label && (
<>
<label
htmlFor={id}
className={classNames(
'text-sm font-medium cursor-pointer select-none',
disabled ? 'text-gray-500' : 'text-gray-300',
)}
>
{label}
</label>
{doc && (
<div className="inline-block ml-1 text-xs text-gray-500 cursor-pointer" onClick={() => openDoc(doc)}>
<CircleHelp className="inline-block w-4 h-4 cursor-pointer" />
</div>
)}
</>
)}
</div>
);
};
interface FormGroupProps {
label?: string;
className?: string;
docKey?: string | null;
doc?: ConfigDoc | null;
children: React.ReactNode;
}
export const FormGroup: React.FC<FormGroupProps> = props => {
const { label, className, children, docKey = null } = props;
let { doc } = props;
if (!doc && docKey) {
doc = getDoc(docKey);
}
return (
<div className={classNames(className)}>
{label && (
<label className={classNames(labelClasses, 'mb-2')}>
{label}{' '}
{doc && (
<div className="inline-block ml-1 text-xs text-gray-500 cursor-pointer" onClick={() => openDoc(doc)}>
<CircleHelp className="inline-block w-4 h-4 cursor-pointer" />
</div>
)}
</label>
)}
<div className="space-y-2">{children}</div>
</div>
);
};
export interface SliderInputProps extends InputProps {
value: number;
onChange: (value: number) => void;
min: number;
max: number;
step?: number;
disabled?: boolean;
showValue?: boolean;
}
export const SliderInput: React.FC<SliderInputProps> = props => {
const { label, value, onChange, min, max, step = 1, disabled, className, docKey = null, showValue = true } = props;
let { doc } = props;
if (!doc && docKey) {
doc = getDoc(docKey);
}
const trackRef = React.useRef<HTMLDivElement | null>(null);
const [dragging, setDragging] = React.useState(false);
const clamp = (v: number) => (v < min ? min : v > max ? max : v);
const snapToStep = (v: number) => {
if (!Number.isFinite(v)) return min;
const steps = Math.round((v - min) / step);
const snapped = min + steps * step;
return clamp(Number(snapped.toFixed(6)));
};
const percent = React.useMemo(() => {
if (max === min) return 0;
const p = ((value - min) / (max - min)) * 100;
return p < 0 ? 0 : p > 100 ? 100 : p;
}, [value, min, max]);
const calcFromClientX = React.useCallback(
(clientX: number) => {
const el = trackRef.current;
if (!el || !Number.isFinite(clientX)) return;
const rect = el.getBoundingClientRect();
const width = rect.right - rect.left;
if (!(width > 0)) return;
// Clamp ratio to [0, 1] so it can never flip ends.
const ratioRaw = (clientX - rect.left) / width;
const ratio = ratioRaw <= 0 ? 0 : ratioRaw >= 1 ? 1 : ratioRaw;
const raw = min + ratio * (max - min);
onChange(snapToStep(raw));
},
[min, max, step, onChange],
);
// Mouse/touch pointer drag
const onPointerDown = (e: React.PointerEvent) => {
if (disabled) return;
e.preventDefault();
// Capture the pointer so moves outside the element are still tracked correctly
try {
(e.currentTarget as HTMLElement).setPointerCapture?.(e.pointerId);
} catch {}
setDragging(true);
calcFromClientX(e.clientX);
const handleMove = (ev: PointerEvent) => {
ev.preventDefault();
calcFromClientX(ev.clientX);
};
const handleUp = (ev: PointerEvent) => {
setDragging(false);
// release capture if we got it
try {
(e.currentTarget as HTMLElement).releasePointerCapture?.(e.pointerId);
} catch {}
window.removeEventListener('pointermove', handleMove);
window.removeEventListener('pointerup', handleUp);
};
window.addEventListener('pointermove', handleMove);
window.addEventListener('pointerup', handleUp);
};
return (
<div className={classNames(className, disabled ? 'opacity-30 cursor-not-allowed' : '')}>
{label && (
<label className={labelClasses}>
{label}{' '}
{doc && (
<div className="inline-block ml-1 text-xs text-gray-500 cursor-pointer" onClick={() => openDoc(doc)}>
<CircleHelp className="inline-block w-4 h-4 cursor-pointer" />
</div>
)}
</label>
)}
<div className="flex items-center gap-3">
<div className="flex-1">
<div
ref={trackRef}
onPointerDown={onPointerDown}
className={classNames(
'relative w-full h-6 select-none outline-none',
disabled ? 'pointer-events-none' : 'cursor-pointer',
)}
>
{/* Thicker track */}
<div className="pointer-events-none absolute left-0 right-0 top-1/2 -translate-y-1/2 h-3 rounded-sm bg-gray-800 border border-gray-700" />
{/* Fill */}
<div
className="pointer-events-none absolute left-0 top-1/2 -translate-y-1/2 h-3 rounded-sm bg-blue-500"
style={{ width: `${percent}%` }}
/>
{/* Thumb */}
<div
onPointerDown={onPointerDown}
className={classNames(
'absolute top-1/2 -translate-y-1/2 -ml-2',
'h-4 w-4 rounded-full bg-white shadow border border-gray-300 cursor-pointer',
'after:content-[""] after:absolute after:inset-[-6px] after:rounded-full after:bg-transparent', // expands hit area
dragging ? 'ring-2 ring-blue-600' : '',
)}
style={{ left: `calc(${percent}% )` }}
/>
</div>
<div className="flex justify-between text-xs text-gray-500 mt-0.5 select-none">
<span>{min}</span>
<span>{max}</span>
</div>
</div>
{showValue && (
<div className="min-w-[3.5rem] text-right text-sm px-3 py-1 bg-gray-800 border border-gray-700 rounded-sm">
{Number.isFinite(value) ? value : ''}
</div>
)}
</div>
</div>
);
};