Update input components

This commit is contained in:
2026-02-16 23:36:32 -05:00
parent da0db483aa
commit a61e7ce19a
10 changed files with 171 additions and 105 deletions

View File

@@ -1,41 +1,47 @@
import { DangerButton } from "$/component/button";
import type { FileInputProps } from "$/types/InputTypes"; import type { FileInputProps } from "$/types/InputTypes";
import { humanReadableBytes } from "$/util/FileUtil"; import { humanReadableBytes } from "$/util/FileUtil";
import clsx from "clsx"; import clsx from "clsx";
import { useEffect, useRef, useState } from "react"; import { useRef, useState } from "react";
import { MdClose } from "react-icons/md";
export default function DragAndDropFileInput({ export default function DragAndDropFileInput({
id, id,
className, className,
name, name,
ariaLabel,
minSize, minSize,
maxSize, maxSize,
showFileName, showFileName = true,
showSize, showSize = true,
onChange, onChange,
disabled, disabled,
children children
}: FileInputProps){ }: Readonly<FileInputProps>){
const [ file, setFile ] = useState<File>(); const [ file, setFile ] = useState<File>();
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
onChange?.(file);
}, [ file, onChange ]);
return ( return (
<label <label
className={clsx( className={clsx(
"flex flex-col items-center justify-center border-2 rounded-lg w-full h-full cursor-pointer", "flex flex-col items-center justify-center border-2 rounded-lg cursor-pointer",
//TODO: Make hover classes
className className
)} )}
onDragOver={(e) => e.preventDefault()} onDragOver={(e) => e.preventDefault()}
onDrop={(e) => { onDrop={(e) => {
e.preventDefault(); e.preventDefault();
setFile(e.dataTransfer.files[0]); const currentFile = e.dataTransfer.files[0];
setFile(currentFile);
if ((minSize && currentFile.size < minSize) || (maxSize && currentFile.size > maxSize)) return;
onChange?.(currentFile);
if(inputRef.current){ inputRef.current.files = e.dataTransfer.files; } if(inputRef.current){ inputRef.current.files = e.dataTransfer.files; }
}} }}
aria-label={ariaLabel}
> >
<input <input
ref={inputRef} ref={inputRef}
@@ -43,24 +49,40 @@ export default function DragAndDropFileInput({
id={id} id={id}
className="sr-only" className="sr-only"
name={name} name={name}
onChange={(e) => setFile(e.target.files?.[0])} onChange={(e) => {
const currentFile = e.target.files?.[0];
setFile(currentFile);
if ((minSize && currentFile && currentFile.size < minSize) || (maxSize && currentFile && currentFile.size > maxSize)) return;
onChange?.(currentFile);
}}
disabled={disabled} disabled={disabled}
/> />
<div <div
className="flex flex-col items-center justify-between w-full h-full px-2" className="flex flex-col items-center justify-between px-2"
> >
<div className="flex flex-row items-center justify-center">
{children} {children}
</div>
<div <div
className="flex flex-row items-center justify-between gap-x-8 w-full" className="flex flex-row items-center justify-between gap-x-2 w-full"
> >
{ {
showFileName && showFileName &&
<div <div className="flex flex-row items-center justify-center gap-x-2">
className="text-center"
>
{file?.name} {file?.name}
</div> </div>
} }
{
file &&
<DangerButton
className="mr-4"
shape="square"
variant="icon"
onClick={(e) => { e.preventDefault(); setFile(undefined); onChange?.(undefined); }}
>
<MdClose size={22} className="fill-danger"/>
</DangerButton>
}
{ {
showSize && showSize &&
<div <div

View File

@@ -1,22 +1,25 @@
import { SecondaryButton } from "$/component/button"; import { DangerButton, SecondaryButton } from "$/component/button";
import type { FileInputProps } from "$/types/InputTypes"; import type { FileInputProps } from "$/types/InputTypes";
import { humanReadableBytes } from "$/util/FileUtil"; import { humanReadableBytes } from "$/util/FileUtil";
import clsx from "clsx"; import clsx from "clsx";
import { useRef, useState } from "react"; import { useRef, useState } from "react";
import { FaRegFolderOpen } from "react-icons/fa6";
import { MdClose } from "react-icons/md";
export default function FileInput({ export default function FileInput({
id, id,
className, className,
name, name,
ariaLabel,
minSize, minSize,
maxSize, maxSize,
showFileName, showFileName = true,
showSize, showSize = true,
onChange, onChange,
disabled, disabled,
children children
}: FileInputProps){ }: Readonly<FileInputProps>){
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const [ file, setFile ] = useState<File>(); const [ file, setFile ] = useState<File>();
@@ -24,7 +27,7 @@ export default function FileInput({
return ( return (
<div <div
className={clsx( className={clsx(
"flex flex-row items-center justify-between w-full border-2 rounded-lg", "flex flex-row items-center justify-between border-2 rounded-lg",
className className
)} )}
> >
@@ -34,7 +37,13 @@ export default function FileInput({
type="file" type="file"
className="sr-only" className="sr-only"
name={name} name={name}
onChange={(e) => { setFile(e.target.files?.[0]); onChange?.(e.target.files?.[0]); }} aria-label={ariaLabel}
onChange={(e) => {
const currentFile = e.target.files?.[0];
setFile(currentFile);
if ((minSize && currentFile && currentFile.size < minSize) || (maxSize && currentFile && currentFile.size > maxSize)) return;
onChange?.(currentFile);
}}
disabled={disabled} disabled={disabled}
/> />
<div <div
@@ -48,6 +57,16 @@ export default function FileInput({
showFileName && showFileName &&
<div>{file?.name}</div> <div>{file?.name}</div>
} }
{
file &&
<DangerButton
shape="square"
variant="icon"
onClick={(e) => { e.preventDefault(); setFile(undefined); onChange?.(undefined); }}
>
<MdClose size={22} className="fill-danger"/>
</DangerButton>
}
{ {
!children && !showFileName && !children && !showFileName &&
<>&nbsp;</> <>&nbsp;</>
@@ -56,12 +75,13 @@ export default function FileInput({
showSize && showSize &&
<div <div
className={clsx( className={clsx(
"ml-4",
{ {
"text-red-600": minSize && file?.size && file?.size < minSize, "text-danger": minSize && file?.size && file?.size < minSize,
"text-red-600 ": maxSize && file?.size && file?.size > maxSize, "text-danger ": maxSize && file?.size && file?.size > maxSize,
"text-green-600": minSize && !maxSize && file?.size && file?.size > minSize, "text-success": minSize && !maxSize && file?.size && file?.size > minSize,
"text-green-600 ": !minSize && maxSize && file?.size && file?.size < maxSize, "text-success ": !minSize && maxSize && file?.size && file?.size < maxSize,
" text-green-600": minSize && maxSize && file?.size && file?.size > minSize && file?.size < maxSize " text-success": minSize && maxSize && file?.size && file?.size > minSize && file?.size < maxSize
} }
)} )}
> >
@@ -75,10 +95,13 @@ export default function FileInput({
<SecondaryButton <SecondaryButton
className="text-nowrap rounded-r-lg" className="text-nowrap rounded-r-lg"
rounding="none" rounding="none"
shape="square"
size="lg"
onClick={() => { inputRef.current?.click(); }} onClick={() => { inputRef.current?.click(); }}
disabled={disabled} disabled={disabled}
aria-label="Select File"
> >
Click Me <FaRegFolderOpen />
</SecondaryButton> </SecondaryButton>
</div> </div>
</div> </div>

View File

@@ -10,22 +10,24 @@ export default function NumberInput({
name, name,
min, min,
max, max,
defaultValue, step,
prefix,
suffix,
value, value,
onChange, onChange,
disabled, disabled,
children children
}: NumberInputProps){ }: Readonly<NumberInputProps>){
return ( return (
<div <div
className={clsx( className={clsx(
"flex flex-row items-center justify-center rounded-lg border-2 w-full", "flex flex-row items-center justify-center rounded-lg border-2",
className className
)} )}
> >
<div <div className="relative flex flex-row items-center justify-center px-2 py-1 w-full">
className="relative flex flex-row items-center justify-center px-2 py-1 w-full" <div className="flex flex-row items-center justify-start">
> { prefix && <span>{prefix}</span> }
<input <input
type="number" type="number"
id={id} id={id}
@@ -37,11 +39,13 @@ export default function NumberInput({
name={name} name={name}
min={min} min={min}
max={max} max={max}
defaultValue={defaultValue} step={step}
value={value} value={value}
onChange={(e) => onChange?.(e.target.valueAsNumber)} onChange={(e) => onChange(e.target.valueAsNumber || 0)}
disabled={disabled} disabled={disabled}
/> />
{ suffix && <span>{suffix}</span> }
</div>
<label <label
className={clsx( className={clsx(
"absolute ml-2 -top-3 left-0 text-sm rounded-md px-1 select-none cursor-default", "absolute ml-2 -top-3 left-0 text-sm rounded-md px-1 select-none cursor-default",

View File

@@ -9,28 +9,31 @@ export default function NumberSlider({
min, min,
max, max,
step, step,
defaultValue,
value, value,
onChange, onChange,
disabled disabled,
}: NumberSliderProps){ ariaLabel
}: Readonly<NumberSliderProps>){
return ( return (
<input <input
type="range" type="range"
id={id} id={id}
className={clsx( className={clsx(
"w-full appearance-none [-moz-range-thumb:background:#04AA6D]", "appearance-none [-moz-range-thumb:background:#04AA6D]",
"h-6 bg-blue-300 accent-blue-600", "h-5 px-0.5 rounded-full bg-primary",
className className
)} )}
name={name} name={name}
min={min} min={min}
max={max} max={max}
step={step} step={step}
defaultValue={defaultValue}
value={value} value={value}
onChange={(e) => onChange?.(e.target.valueAsNumber)} onChange={(e) => onChange(e.target.valueAsNumber || 0)}
disabled={disabled} disabled={disabled}
aria-label={ariaLabel}
aria-valuemin={min}
aria-valuemax={max}
aria-valuenow={value}
/> />
); );
} }

View File

@@ -3,15 +3,13 @@ import { ListboxOption } from "@headlessui/react";
import clsx from "clsx"; import clsx from "clsx";
export default function OptionInput(props: OptionInputProps){ export default function OptionInput({
const {
id, id,
className, className,
value, value,
disabled,
children children
} = props; }: Readonly<OptionInputProps>){
return ( return (
<ListboxOption <ListboxOption
id={id} id={id}
@@ -20,6 +18,7 @@ export default function OptionInput(props: OptionInputProps){
className className
)} )}
value={value} value={value}
disabled={disabled}
> >
{children} {children}
</ListboxOption> </ListboxOption>

View File

@@ -4,28 +4,28 @@ import clsx from "clsx";
import { BsChevronDown } from "react-icons/bs"; import { BsChevronDown } from "react-icons/bs";
export default function SelectInput(props: SelectInputProps){ export default function SelectInput({
const { placeholder,
label,
value, value,
onChange, onChange,
disabled,
children children
} = props; }: Readonly<SelectInputProps>){
return ( return (
<Listbox <Listbox
value={value} value={value}
onChange={onChange} onChange={onChange}
disabled={disabled}
> >
<ListboxButton <ListboxButton
className={clsx( className={clsx(
"group relative flex flex-row items-center justify-between w-full", "group relative flex flex-row items-center justify-between w-full",
"border-2 px-2 py-1 rounded-lg" "border-2 px-2 py-1 rounded-lg",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
//"not-data-open:rounded-lg data-open:rounded-t-lg" //"not-data-open:rounded-lg data-open:rounded-t-lg"
)} )}
> >
<span>{label}</span> <span>{placeholder}</span>
<span className="block group-data-open:rotate-180 transition-transform duration-250"><BsChevronDown size={22}/></span> <span className="block group-data-open:rotate-180 transition-transform duration-250"><BsChevronDown size={22}/></span>
</ListboxButton> </ListboxButton>
<ListboxOptions <ListboxOptions

View File

@@ -1,23 +1,27 @@
import type { TextAreaProps } from "$/types/InputTypes"; import type { TextAreaProps } from "$/types/InputTypes";
import clsx from "clsx"; import clsx from "clsx";
import { useId } from "react";
export default function TextArea({ export default function TextArea({
id = crypto.randomUUID().replaceAll("-", ""), id,
className, className,
inputClassName, inputClassName,
labelClassName, labelClassName,
name, name,
maxLength, maxLength,
rows, rows = 3,
cols, cols,
spellCheck, spellCheck,
placeholder, placeholder,
defaultValue,
value, value,
onChange, onChange,
disabled disabled
}: TextAreaProps){ }: Readonly<TextAreaProps>){
const componentId = useId();
const activeId = id ?? componentId;
return ( return (
<div <div
className={clsx( className={clsx(
@@ -29,7 +33,7 @@ export default function TextArea({
className="relative flex flex-row items-center justify-center px-2 py-1 w-full" className="relative flex flex-row items-center justify-center px-2 py-1 w-full"
> >
<textarea <textarea
id={id} id={activeId}
className={clsx( className={clsx(
"peer bg-transparent outline-none placeholder-transparent w-full", "peer bg-transparent outline-none placeholder-transparent w-full",
inputClassName inputClassName
@@ -39,7 +43,6 @@ export default function TextArea({
maxLength={maxLength} maxLength={maxLength}
rows={rows} rows={rows}
cols={cols} cols={cols}
defaultValue={defaultValue}
value={value} value={value}
onChange={onChange} onChange={onChange}
disabled={disabled} disabled={disabled}
@@ -54,7 +57,7 @@ export default function TextArea({
labelClassName labelClassName
)} )}
style={{ transitionProperty: "top, left, font-size, line-height", transitionTimingFunction: "cubic-bezier(0.4 0, 0.2, 1)", transitionDuration: "250ms" }} style={{ transitionProperty: "top, left, font-size, line-height", transitionTimingFunction: "cubic-bezier(0.4 0, 0.2, 1)", transitionDuration: "250ms" }}
htmlFor={id} htmlFor={activeId}
> >
{placeholder} {placeholder}
</label> </label>

View File

@@ -1,9 +1,10 @@
import type { TextInputProps } from "$/types/InputTypes"; import type { TextInputProps } from "$/types/InputTypes";
import clsx from "clsx"; import clsx from "clsx";
import { useId } from "react";
export default function TextInput({ export default function TextInput({
id = crypto.randomUUID().replaceAll("-", ""), id,
className, className,
inputClassName, inputClassName,
labelClassName, labelClassName,
@@ -11,11 +12,14 @@ export default function TextInput({
maxLength, maxLength,
spellCheck, spellCheck,
placeholder, placeholder,
defaultValue,
value, value,
onChange, onChange,
disabled disabled
}: TextInputProps){ }: Readonly<TextInputProps>){
const componentId = useId();
const activeId = id ?? componentId;
return ( return (
<div <div
className={clsx( className={clsx(
@@ -28,7 +32,7 @@ export default function TextInput({
> >
<input <input
type="text" type="text"
id={id} id={activeId}
className={clsx( className={clsx(
"peer bg-transparent outline-none placeholder-transparent w-full", "peer bg-transparent outline-none placeholder-transparent w-full",
inputClassName inputClassName
@@ -36,7 +40,6 @@ export default function TextInput({
name={name} name={name}
placeholder={placeholder} placeholder={placeholder}
maxLength={maxLength} maxLength={maxLength}
defaultValue={defaultValue}
value={value} value={value}
onChange={onChange} onChange={onChange}
disabled={disabled} disabled={disabled}
@@ -51,7 +54,7 @@ export default function TextInput({
labelClassName labelClassName
)} )}
style={{ transitionProperty: "top, left, font-size, line-height", transitionTimingFunction: "cubic-bezier(0.4 0, 0.2, 1)", transitionDuration: "250ms" }} style={{ transitionProperty: "top, left, font-size, line-height", transitionTimingFunction: "cubic-bezier(0.4 0, 0.2, 1)", transitionDuration: "250ms" }}
htmlFor={id} htmlFor={activeId}
> >
{placeholder} {placeholder}
</label> </label>

View File

@@ -11,7 +11,6 @@ export interface TextInputProps {
maxLength?: number; maxLength?: number;
spellCheck?: boolean; spellCheck?: boolean;
placeholder?: string; placeholder?: string;
defaultValue?: string;
value?: string; value?: string;
onChange?: ChangeEventHandler<HTMLInputElement>; onChange?: ChangeEventHandler<HTMLInputElement>;
disabled?: boolean; disabled?: boolean;
@@ -26,7 +25,6 @@ export interface TextAreaProps {
maxLength?: number; maxLength?: number;
spellCheck?: boolean; spellCheck?: boolean;
placeholder?: string; placeholder?: string;
defaultValue?: string;
value?: string; value?: string;
disabled?: boolean; disabled?: boolean;
rows?: number; rows?: number;
@@ -35,9 +33,10 @@ export interface TextAreaProps {
} }
export interface SelectInputProps { export interface SelectInputProps {
label: React.ReactNode; placeholder: React.ReactNode;
value?: string; value?: string;
onChange?: (newValue: string) => void; onChange?: (newValue: string) => void;
disabled?: boolean;
children: React.ReactNode; children: React.ReactNode;
} }
@@ -45,6 +44,7 @@ export interface OptionInputProps {
id?: string; id?: string;
className?: string; className?: string;
value: string; value: string;
disabled?: boolean;
children: React.ReactNode; children: React.ReactNode;
} }
@@ -88,9 +88,11 @@ export interface NumberInputProps {
name?: string; name?: string;
min?: number; min?: number;
max?: number; max?: number;
defaultValue?: number; step?: number;
value?: number; prefix?: string;
onChange?: (newValue: number) => void; suffix?: string;
value: number;
onChange: (newValue: number) => void;
disabled?: boolean; disabled?: boolean;
children?: React.ReactNode; children?: React.ReactNode;
} }
@@ -102,20 +104,21 @@ export interface NumberSliderProps {
min?: number; min?: number;
max?: number; max?: number;
step?: number; step?: number;
defaultValue?: number; value: number;
value?: number; onChange: (newValue: number) => void;
onChange?: (newValue: number) => void;
disabled?: boolean; disabled?: boolean;
ariaLabel?: string;
} }
export interface FileInputProps { export interface FileInputProps {
id?: string; id?: string;
className?: string; className?: string;
name?: string; name?: string;
ariaLabel?: string;
minSize?: number; minSize?: number;
maxSize?: number; maxSize?: number;
showFileName?: boolean; showFileName: boolean;
showSize?: boolean; showSize: boolean;
onChange?: (newFile: File | undefined) => void; onChange?: (newFile: File | undefined) => void;
disabled?: boolean; disabled?: boolean;
children?: React.ReactNode; children?: React.ReactNode;

View File

@@ -14,6 +14,7 @@ import WarningCheckbox from "$/component/input/checkbox/WarningCheckbox";
import DateInput from "$/component/input/date/DateInput"; import DateInput from "$/component/input/date/DateInput";
import DateTimeInput from "$/component/input/date/DateTimeInput"; import DateTimeInput from "$/component/input/date/DateTimeInput";
import TimeInput from "$/component/input/date/TimeInput"; import TimeInput from "$/component/input/date/TimeInput";
import NumberSlider from "$/component/input/number/NumberSlider";
import DangerRadioButton from "$/component/input/radio/DangerRadioButton"; import DangerRadioButton from "$/component/input/radio/DangerRadioButton";
import DarkRadioButton from "$/component/input/radio/DarkRadioButton"; import DarkRadioButton from "$/component/input/radio/DarkRadioButton";
import InfoRadioButton from "$/component/input/radio/InfoRadioButton"; import InfoRadioButton from "$/component/input/radio/InfoRadioButton";
@@ -572,6 +573,7 @@ export function TextContent(){
]; ];
const [ selected, setSelected ] = useState(selectOptions[0]); const [ selected, setSelected ] = useState(selectOptions[0]);
const [ numberValue, setNumberValue ] = useState(0);
return ( return (
<div <div
@@ -584,7 +586,7 @@ export function TextContent(){
<TextArea placeholder="Textarea" className="resize" labelClassName="bg-(--bg-color) peer-focus:bg-(--bg-color)"/> <TextArea placeholder="Textarea" className="resize" labelClassName="bg-(--bg-color) peer-focus:bg-(--bg-color)"/>
</TextDisplay> </TextDisplay>
<TextDisplay title="Select"> <TextDisplay title="Select">
<SelectInput label={selected.label} onChange={(newValue) => setSelected(selectOptions.find((option) => option.value === newValue) || selectOptions[0])}> <SelectInput placeholder={selected.label} onChange={(newValue) => setSelected(selectOptions.find((option) => option.value === newValue) || selectOptions[0])}>
{ {
selectOptions.map((option) => ( selectOptions.map((option) => (
<OptionInput <OptionInput
@@ -603,20 +605,24 @@ export function TextContent(){
> >
<NumberInput <NumberInput
labelClassName="bg-(--bg-color)" labelClassName="bg-(--bg-color)"
value={numberValue}
onChange={setNumberValue}
> >
Number Test Number Test
</NumberInput> </NumberInput>
</TextDisplay> </TextDisplay>
{/* {/* */
<TextDisplay <TextDisplay
title="Number Slider" title="Number Slider"
> >
<NumberSlider <NumberSlider
min={0} min={0}
max={10} max={10}
value={numberValue}
onChange={setNumberValue}
/> />
</TextDisplay> </TextDisplay>
*/} /* */}
</div> </div>
); );
} }