Add boilerplate files and licenses
This commit is contained in:
68
src/components/ui/Button.tsx
Normal file
68
src/components/ui/Button.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import clsx from 'clsx';
|
||||
import { JSX } from 'preact';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import { Link } from 'wouter';
|
||||
|
||||
interface ButtonProps extends JSX.HTMLAttributes<HTMLButtonElement> {
|
||||
children: any;
|
||||
size?: 'sm' | 'md' | 'xs';
|
||||
disabled?: boolean;
|
||||
variant?: 'primary' | 'secondary' | 'outlined' | 'tertiary' | 'ghost' | 'icon';
|
||||
type?: 'button' | 'submit' | 'reset';
|
||||
to?: string;
|
||||
target?: string;
|
||||
rel?: string;
|
||||
}
|
||||
|
||||
function Button({
|
||||
children,
|
||||
className = '',
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
type = 'button',
|
||||
to,
|
||||
target,
|
||||
rel,
|
||||
disabled,
|
||||
...props
|
||||
}: ButtonProps) {
|
||||
const variantClasses = {
|
||||
primary: 'bg-face hover:bg-[#d0d2d0] active:bg-[#c5c7c5]',
|
||||
secondary: 'bg-gray-100 hover:bg-gray-300 active:bg-gray-400',
|
||||
tertiary: 'bg-[#c6d1d7] hover:bg-gray-100 active:bg-gray-200',
|
||||
outlined: 'bg-transparent hover:bg-black/10 active:bg-black/20 border-1 border-black',
|
||||
ghost: 'border-0 bg-transparent hover:bg-black/10 active:bg-black/20',
|
||||
icon: 'flex-shrink-0 !py-1 !px-1 border-0 !h-auto',
|
||||
} as const;
|
||||
|
||||
const sizeClasses = {
|
||||
xs: 'text-xs py-1 px-2 h-[24px]',
|
||||
sm: 'text-sm py-1 px-2 h-[28px]',
|
||||
md: 'text-sm py-2 px-4 h-[42px]',
|
||||
} as const;
|
||||
|
||||
const baseClasses =
|
||||
'text-black cursor-pointer border-1 border-black disabled:opacity-80 disabled:cursor-not-allowed disabled:text-gray-400 outline-none transition-colors duration-200';
|
||||
|
||||
const linkDisabledClasses = disabled ? 'pointer-events-none opacity-80 text-gray-400' : '';
|
||||
|
||||
const composed = twMerge(
|
||||
clsx(baseClasses, variantClasses[variant], sizeClasses[size], linkDisabledClasses, className),
|
||||
);
|
||||
|
||||
if (to) {
|
||||
return (
|
||||
<Link href={to} className={composed} target={target} rel={rel} aria-disabled={disabled}>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button type={type} className={composed} disabled={disabled} {...props}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default Button;
|
||||
56
src/components/ui/CheckBox.tsx
Normal file
56
src/components/ui/CheckBox.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import clsx from 'clsx';
|
||||
import { JSX } from 'preact';
|
||||
import { useId } from 'preact/hooks';
|
||||
import IconInfo from '../icons/info.svg?react';
|
||||
|
||||
function CheckBox({
|
||||
checked,
|
||||
onChange,
|
||||
title,
|
||||
helperText,
|
||||
disabled,
|
||||
className,
|
||||
}: {
|
||||
checked: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
title: string;
|
||||
helperText?: string;
|
||||
disabled: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
const _id = useId();
|
||||
|
||||
return (
|
||||
<label className={clsx('flex items-center gap-2 text-sm', className)} htmlFor={_id}>
|
||||
<input
|
||||
type="checkbox"
|
||||
id={_id}
|
||||
checked={checked}
|
||||
onChange={(e: JSX.TargetedEvent<HTMLInputElement>) => onChange(e.currentTarget.checked)}
|
||||
disabled={disabled}
|
||||
className={clsx('w-4 h-4', disabled && 'opacity-50')}
|
||||
/>
|
||||
<span className={clsx('overflow-hidden truncate', disabled && 'opacity-50')} title={title}>
|
||||
{title}
|
||||
</span>
|
||||
|
||||
{helperText && (
|
||||
<div className="relative">
|
||||
<div className="text-gray-400 cursor-help peer">
|
||||
<IconInfo className="w-4 h-4" />
|
||||
</div>
|
||||
<div
|
||||
className={clsx(
|
||||
'absolute shadow-lg bottom-full left-1/2 -translate-x-1/2 mb-2 p-3 bg-[#b0babe] text-black border border-black text-sm z-2',
|
||||
'opacity-0 peer-hover:opacity-100 transition-opacity duration-300 pointer-events-none min-w-125 max-w-150',
|
||||
)}
|
||||
>
|
||||
{helperText}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export default CheckBox;
|
||||
51
src/components/ui/Dialog.tsx
Normal file
51
src/components/ui/Dialog.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import clsx from 'clsx';
|
||||
import { useEffect, useRef } from 'preact/hooks';
|
||||
|
||||
interface DialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
children: any;
|
||||
className?: string;
|
||||
containerClassName?: string;
|
||||
}
|
||||
|
||||
function Dialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
children,
|
||||
className = '',
|
||||
containerClassName = '',
|
||||
}: DialogProps) {
|
||||
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const dialog = dialogRef.current;
|
||||
|
||||
if (!dialog) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isOpen) {
|
||||
dialog.showModal();
|
||||
} else {
|
||||
dialog.close();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<dialog
|
||||
ref={dialogRef}
|
||||
className={clsx(
|
||||
'shadow-lg backdrop:bg-black/40 fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 border border-black outline-none overflow-visible',
|
||||
className,
|
||||
)}
|
||||
onClose={onClose}
|
||||
>
|
||||
<div className={clsx('bg-white p-4 rounded-md min-w-75 min-h-25 max-h-[95vh] overflow-y-auto', containerClassName)}>
|
||||
{children}
|
||||
</div>
|
||||
</dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default Dialog;
|
||||
84
src/components/ui/FileInput.tsx
Normal file
84
src/components/ui/FileInput.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import { useRef } from 'preact/hooks';
|
||||
import IconFile from '../icons/file.svg?react';
|
||||
import IconTrash from '../icons/trash.svg?react';
|
||||
import Button from './Button';
|
||||
|
||||
interface FileInputProps {
|
||||
files: File[];
|
||||
onFilesChange: (files: File[]) => void;
|
||||
maxFiles?: number;
|
||||
disabled?: boolean;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
function FileInput({
|
||||
files,
|
||||
onFilesChange,
|
||||
maxFiles = 3,
|
||||
disabled = false,
|
||||
label = 'Attach files',
|
||||
}: FileInputProps) {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleFileSelect = (e: Event) => {
|
||||
const target = e.target as HTMLInputElement;
|
||||
if (target.files) {
|
||||
const newFiles = Array.from(target.files);
|
||||
onFilesChange([...files, ...newFiles].slice(0, maxFiles));
|
||||
}
|
||||
};
|
||||
|
||||
const removeFile = (index: number) => {
|
||||
onFilesChange(files.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const openFileDialog = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="text-sm font-medium">
|
||||
{label} (optional, max {maxFiles})
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{files.map((file, index) => (
|
||||
<div
|
||||
key={`${file.name}-${index}`}
|
||||
className="flex gap-2 items-center p-2 bg-gray-50 rounded"
|
||||
>
|
||||
<IconFile className="w-6 h-6 flex-shrink-0" />
|
||||
<span className="text-sm truncate flex-1 max-w-[500px]">{file.name}</span>
|
||||
<span className="text-xs opacity-60 flex-shrink-0 ml-auto">
|
||||
{(file.size / 1024).toFixed(2)}KB
|
||||
</span>
|
||||
<Button variant="icon" onClick={() => removeFile(index)} disabled={disabled}>
|
||||
<IconTrash className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
{files.length < maxFiles && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={openFileDialog}
|
||||
className="flex items-center gap-2 w-fit"
|
||||
disabled={disabled}
|
||||
>
|
||||
<span className="text-2xl">+</span>
|
||||
<span className="text-sm">Add file</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
onChange={handleFileSelect}
|
||||
className="hidden"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default FileInput;
|
||||
66
src/components/ui/Input.tsx
Normal file
66
src/components/ui/Input.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import clsx from 'clsx';
|
||||
import { JSX } from 'preact';
|
||||
import { forwardRef } from 'preact/compat';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
interface BaseInputProps {
|
||||
label?: string;
|
||||
type?: 'text' | 'email' | 'textarea' | 'file';
|
||||
error?: string;
|
||||
className?: string;
|
||||
value?: string;
|
||||
disabled?: boolean;
|
||||
onChange?: (e: Event) => void;
|
||||
required?: boolean;
|
||||
multiple?: boolean;
|
||||
accept?: string;
|
||||
placeholder?: string;
|
||||
rows?: number;
|
||||
}
|
||||
|
||||
type InputProps = BaseInputProps &
|
||||
(JSX.HTMLAttributes<HTMLInputElement> | JSX.HTMLAttributes<HTMLTextAreaElement>);
|
||||
|
||||
const Input = forwardRef<HTMLInputElement | HTMLTextAreaElement, InputProps>(
|
||||
({ label, type = 'text', className = '', disabled = false, ...props }, ref) => {
|
||||
const id = props.id || label?.toLowerCase().replace(/\s+/g, '-');
|
||||
const inputClasses = twMerge(
|
||||
clsx(
|
||||
'w-full px-3 py-2 border-1 border-black focus:outline-none focus:ring-0 bg-gray-50 placeholder:text-gray-400',
|
||||
disabled && 'opacity-50',
|
||||
className,
|
||||
),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
{label && (
|
||||
<label
|
||||
className={clsx('text-sm font-medium text-black', disabled && 'opacity-50')}
|
||||
htmlFor={id}
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
{type === 'textarea' ? (
|
||||
<textarea
|
||||
ref={ref as preact.Ref<HTMLTextAreaElement>}
|
||||
className={inputClasses}
|
||||
id={id}
|
||||
{...(props as JSX.HTMLAttributes<HTMLTextAreaElement>)}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
ref={ref as preact.Ref<HTMLInputElement>}
|
||||
type={type}
|
||||
className={inputClasses}
|
||||
id={id}
|
||||
{...(props as JSX.HTMLAttributes<HTMLInputElement>)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export default Input;
|
||||
60
src/components/ui/Select.tsx
Normal file
60
src/components/ui/Select.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import clsx from 'clsx';
|
||||
import { JSX } from 'preact';
|
||||
|
||||
interface SelectProps extends Omit<JSX.SelectHTMLAttributes<HTMLSelectElement>, 'size'> {
|
||||
children: any;
|
||||
className?: string;
|
||||
size?: 'sm' | 'md';
|
||||
variant?: 'primary' | 'secondary' | 'outlined' | 'tertiary' | 'ghost' | 'icon';
|
||||
}
|
||||
|
||||
function Select({
|
||||
children,
|
||||
className = '',
|
||||
size = 'md',
|
||||
variant = 'primary',
|
||||
...props
|
||||
}: SelectProps) {
|
||||
const variantClasses = {
|
||||
primary: 'bg-gray-100 hover:bg-[#d0d2d0] active:bg-[#c5c7c5] border-1 border-black',
|
||||
secondary: 'bg-gray-100 hover:bg-gray-300 active:bg-gray-400 border-1 border-black',
|
||||
tertiary: 'bg-[#c6d1d7] hover:bg-gray-100 active:bg-gray-200 border-1 border-black',
|
||||
outlined: 'bg-transparent hover:bg-black/10 active:bg-black/20 border-1 border-black',
|
||||
ghost: 'border-0 bg-transparent hover:bg-black/10 active:bg-black/20',
|
||||
icon: 'border-0 bg-transparent',
|
||||
} as const;
|
||||
|
||||
const sizeClasses = {
|
||||
sm: {
|
||||
wrapper: 'h-[28px]',
|
||||
select: 'relative h-[28px] px-1 py-0 text-sm -top-[1px]',
|
||||
},
|
||||
md: {
|
||||
wrapper: 'h-[42px]',
|
||||
select: 'h-[42px] p-2 text-sm',
|
||||
},
|
||||
} as const;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'px-2 transition-colors duration-200',
|
||||
variantClasses[variant],
|
||||
sizeClasses[size].wrapper,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<select
|
||||
className={clsx(
|
||||
'outline-none disabled:opacity-80 disabled:cursor-not-allowed disabled:text-gray-400 w-full cursor-pointer bg-transparent',
|
||||
sizeClasses[size].select,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Select;
|
||||
64
src/components/ui/Toast.tsx
Normal file
64
src/components/ui/Toast.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import clsx from 'clsx';
|
||||
import { useAtom } from 'jotai';
|
||||
import { useEffect, useRef } from 'preact/hooks';
|
||||
import { removeToastAtom, type ToastMessage, toastsAtom } from '../../atoms/toast';
|
||||
import Button from './Button';
|
||||
|
||||
const TOAST_DURATION = 5000;
|
||||
|
||||
function ToastItem({ toast, onRemove }: { toast: ToastMessage; onRemove: (id: string) => void }) {
|
||||
const toastRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (toastRef.current) {
|
||||
toastRef.current.showPopover();
|
||||
}
|
||||
}, [toast.id]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
onRemove(toast.id);
|
||||
}, TOAST_DURATION);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [toast.id, onRemove]);
|
||||
|
||||
return (
|
||||
<div ref={toastRef} popover>
|
||||
<div
|
||||
className={clsx(
|
||||
'flex gap-8 items-center px-4 py-3 shadow-md max-w-125 w-full text-black/90 border border-gray-800',
|
||||
'transform transition-all duration-300 ease-out',
|
||||
toast.severity === 'error' ? 'bg-red-500' : 'bg-[#7af07e]',
|
||||
)}
|
||||
>
|
||||
<p className="flex-1">{toast.message}</p>
|
||||
<Button variant="primary" size="sm" onClick={() => onRemove(toast.id)} className="ml-auto">
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Toast() {
|
||||
const [toasts] = useAtom(toastsAtom);
|
||||
const [, removeToast] = useAtom(removeToastAtom);
|
||||
|
||||
return (
|
||||
<>
|
||||
{toasts.map((toast) => (
|
||||
<ToastItem key={toast.id} toast={toast} onRemove={removeToast} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default Toast;
|
||||
Reference in New Issue
Block a user