mirror of
https://github.com/ostris/ai-toolkit.git
synced 2026-01-26 16:39:47 +00:00
208 lines
6.0 KiB
TypeScript
208 lines
6.0 KiB
TypeScript
'use client';
|
|
|
|
import React, { forwardRef } from 'react';
|
|
import classNames from 'classnames';
|
|
import dynamic from "next/dynamic";
|
|
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;
|
|
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>(
|
|
({ label, value, onChange, placeholder, required, disabled, type = 'text', className }, ref) => {
|
|
return (
|
|
<div className={classNames(className)}>
|
|
{label && <label className={labelClasses}>{label}</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;
|
|
onChange: (value: number) => void;
|
|
min?: number;
|
|
max?: number;
|
|
}
|
|
|
|
export const NumberInput = (props: NumberInputProps) => {
|
|
const { label, value, onChange, placeholder, required, min, max } = props;
|
|
|
|
// 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}</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;
|
|
onChange: (value: string) => void;
|
|
options: { value: string; label: string }[];
|
|
}
|
|
|
|
export const SelectInput = (props: SelectInputProps) => {
|
|
const { label, value, onChange, options } = props;
|
|
const selectedOption = options.find(option => option.value === value);
|
|
return (
|
|
<div className={classNames(props.className)}>
|
|
{label && <label className={labelClasses}>{label}</label>}
|
|
<Select
|
|
value={selectedOption}
|
|
options={options}
|
|
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;
|
|
checked: boolean;
|
|
onChange: (checked: boolean) => void;
|
|
className?: string;
|
|
required?: boolean;
|
|
disabled?: boolean;
|
|
}
|
|
|
|
export const Checkbox = (props: CheckboxProps) => {
|
|
const { label, checked, onChange, required, disabled } = props;
|
|
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-600' : 'bg-gray-700',
|
|
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>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
interface FormGroupProps {
|
|
label?: string;
|
|
className?: string;
|
|
children: React.ReactNode;
|
|
}
|
|
|
|
export const FormGroup: React.FC<FormGroupProps> = ({ label, className, children }) => {
|
|
return (
|
|
<div className={classNames(className)}>
|
|
{label && <label className={labelClasses}>{label}</label>}
|
|
<div className="px-4 space-y-2">{children}</div>
|
|
</div>
|
|
);
|
|
};
|