uup
This commit is contained in:
214
app/components/ui/AutocompleteInput.tsx
Normal file
214
app/components/ui/AutocompleteInput.tsx
Normal file
@@ -0,0 +1,214 @@
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { Input } from "./Input";
|
||||
|
||||
interface AutocompleteOption {
|
||||
value: string;
|
||||
label: string;
|
||||
data?: any;
|
||||
}
|
||||
|
||||
interface AutocompleteInputProps {
|
||||
name?: string;
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
onSelect?: (option: AutocompleteOption) => void;
|
||||
options: AutocompleteOption[];
|
||||
error?: string;
|
||||
required?: boolean;
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export function AutocompleteInput({
|
||||
name,
|
||||
label,
|
||||
placeholder,
|
||||
value,
|
||||
onChange,
|
||||
onSelect,
|
||||
options,
|
||||
error,
|
||||
required,
|
||||
disabled,
|
||||
loading
|
||||
}: AutocompleteInputProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [highlightedIndex, setHighlightedIndex] = useState(-1);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const listRef = useRef<HTMLUListElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Filter options based on input value
|
||||
const filteredOptions = options.filter(option =>
|
||||
option.label.toLowerCase().includes(value.toLowerCase()) ||
|
||||
option.value.toLowerCase().includes(value.toLowerCase())
|
||||
);
|
||||
|
||||
// Handle input change
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newValue = e.target.value;
|
||||
onChange(newValue);
|
||||
setIsOpen(true);
|
||||
setHighlightedIndex(-1);
|
||||
};
|
||||
|
||||
// Handle option selection
|
||||
const handleOptionSelect = (option: AutocompleteOption) => {
|
||||
onChange(option.value);
|
||||
onSelect?.(option);
|
||||
setIsOpen(false);
|
||||
setHighlightedIndex(-1);
|
||||
};
|
||||
|
||||
// Handle keyboard navigation
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (!isOpen) {
|
||||
if (e.key === "ArrowDown" || e.key === "Enter") {
|
||||
setIsOpen(true);
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
switch (e.key) {
|
||||
case "ArrowDown":
|
||||
e.preventDefault();
|
||||
setHighlightedIndex(prev =>
|
||||
prev < filteredOptions.length - 1 ? prev + 1 : 0
|
||||
);
|
||||
break;
|
||||
case "ArrowUp":
|
||||
e.preventDefault();
|
||||
setHighlightedIndex(prev =>
|
||||
prev > 0 ? prev - 1 : filteredOptions.length - 1
|
||||
);
|
||||
break;
|
||||
case "Enter":
|
||||
e.preventDefault();
|
||||
if (highlightedIndex >= 0 && filteredOptions[highlightedIndex]) {
|
||||
handleOptionSelect(filteredOptions[highlightedIndex]);
|
||||
}
|
||||
break;
|
||||
case "Escape":
|
||||
setIsOpen(false);
|
||||
setHighlightedIndex(-1);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, []);
|
||||
|
||||
// Scroll highlighted option into view
|
||||
useEffect(() => {
|
||||
if (highlightedIndex >= 0 && listRef.current) {
|
||||
const highlightedElement = listRef.current.children[highlightedIndex] as HTMLElement;
|
||||
if (highlightedElement) {
|
||||
highlightedElement.scrollIntoView({
|
||||
block: "nearest",
|
||||
behavior: "smooth"
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [highlightedIndex]);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative">
|
||||
<Input
|
||||
ref={inputRef}
|
||||
name={name}
|
||||
label={label}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={handleInputChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={() => setIsOpen(true)}
|
||||
error={error}
|
||||
required={required}
|
||||
disabled={disabled}
|
||||
endIcon={
|
||||
<div className="pointer-events-auto">
|
||||
{loading ? (
|
||||
<svg className="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
) : isOpen ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 15l7-7 7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(true)}
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Dropdown */}
|
||||
{isOpen && filteredOptions.length > 0 && (
|
||||
<div className="absolute z-50 w-full mt-1 bg-white border border-gray-300 rounded-md shadow-lg max-h-60 overflow-auto">
|
||||
<ul ref={listRef} className="py-1">
|
||||
{filteredOptions.map((option, index) => (
|
||||
<li
|
||||
key={option.value}
|
||||
className={`px-3 py-2 cursor-pointer text-sm ${
|
||||
index === highlightedIndex
|
||||
? "bg-blue-50 text-blue-900"
|
||||
: "text-gray-900 hover:bg-gray-50"
|
||||
}`}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleOptionSelect(option);
|
||||
}}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault(); // Prevent input from losing focus
|
||||
}}
|
||||
onMouseEnter={() => setHighlightedIndex(index)}
|
||||
>
|
||||
<div className="font-medium">{option.value}</div>
|
||||
{option.label !== option.value && (
|
||||
<div className="text-xs text-gray-500 mt-1">{option.label}</div>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* No results */}
|
||||
{isOpen && filteredOptions.length === 0 && value.length > 0 && (
|
||||
<div className="absolute z-50 w-full mt-1 bg-white border border-gray-300 rounded-md shadow-lg">
|
||||
<div className="px-3 py-2 text-sm text-gray-500 text-center">
|
||||
لا توجد نتائج مطابقة
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
92
app/components/ui/Button.tsx
Normal file
92
app/components/ui/Button.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import { ReactNode, ButtonHTMLAttributes } from 'react';
|
||||
import { getButtonClasses, defaultLayoutConfig, type LayoutConfig } from '~/lib/layout-utils';
|
||||
|
||||
interface ButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'className'> {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
config?: Partial<LayoutConfig>;
|
||||
variant?: 'primary' | 'secondary' | 'danger' | 'outline' | 'ghost';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
fullWidth?: boolean;
|
||||
loading?: boolean;
|
||||
icon?: ReactNode;
|
||||
iconPosition?: 'start' | 'end';
|
||||
}
|
||||
|
||||
export function Button({
|
||||
children,
|
||||
className = '',
|
||||
config = {},
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
fullWidth = false,
|
||||
loading = false,
|
||||
icon,
|
||||
iconPosition = 'start',
|
||||
disabled,
|
||||
...props
|
||||
}: ButtonProps) {
|
||||
const layoutConfig = { ...defaultLayoutConfig, ...config };
|
||||
|
||||
const baseClasses = getButtonClasses(variant as any, size);
|
||||
|
||||
const variantClasses = {
|
||||
primary: 'bg-blue-600 hover:bg-blue-700 text-white focus:ring-blue-500 disabled:bg-blue-300',
|
||||
secondary: 'bg-gray-200 hover:bg-gray-300 text-gray-900 focus:ring-gray-500 disabled:bg-gray-100',
|
||||
danger: 'bg-red-600 hover:bg-red-700 text-white focus:ring-red-500 disabled:bg-red-300',
|
||||
outline: 'border border-gray-300 bg-white hover:bg-gray-50 text-gray-700 focus:ring-blue-500 disabled:bg-gray-50',
|
||||
ghost: 'bg-transparent hover:bg-gray-100 text-gray-700 focus:ring-gray-500 disabled:text-gray-400',
|
||||
};
|
||||
|
||||
const fullWidthClass = fullWidth ? 'w-full' : '';
|
||||
const disabledClass = (disabled || loading) ? 'cursor-not-allowed opacity-50' : '';
|
||||
|
||||
const iconSpacing = layoutConfig.direction === 'rtl' ? 'space-x-reverse' : '';
|
||||
const iconOrderClass = iconPosition === 'end' ? 'flex-row-reverse' : '';
|
||||
|
||||
return (
|
||||
<button
|
||||
className={`${baseClasses} ${variantClasses[variant]} ${fullWidthClass} ${disabledClass} ${iconSpacing} ${iconOrderClass} ${className}`}
|
||||
disabled={disabled || loading}
|
||||
dir={layoutConfig.direction}
|
||||
{...props}
|
||||
>
|
||||
{loading && (
|
||||
<svg
|
||||
className="animate-spin -ml-1 mr-3 h-5 w-5 text-current"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
|
||||
{icon && iconPosition === 'start' && !loading && (
|
||||
<span className={layoutConfig.direction === 'rtl' ? 'ml-2' : 'mr-2'}>
|
||||
{icon}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<span>{children}</span>
|
||||
|
||||
{icon && iconPosition === 'end' && !loading && (
|
||||
<span className={layoutConfig.direction === 'rtl' ? 'mr-2' : 'ml-2'}>
|
||||
{icon}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
118
app/components/ui/Card.tsx
Normal file
118
app/components/ui/Card.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { defaultLayoutConfig, type LayoutConfig } from '~/lib/layout-utils';
|
||||
|
||||
interface CardProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
config?: Partial<LayoutConfig>;
|
||||
padding?: 'none' | 'sm' | 'md' | 'lg';
|
||||
shadow?: 'none' | 'sm' | 'md' | 'lg' | 'xl';
|
||||
rounded?: 'none' | 'sm' | 'md' | 'lg' | 'xl';
|
||||
border?: boolean;
|
||||
hover?: boolean;
|
||||
}
|
||||
|
||||
export function Card({
|
||||
children,
|
||||
className = '',
|
||||
config = {},
|
||||
padding = 'md',
|
||||
shadow = 'md',
|
||||
rounded = 'lg',
|
||||
border = true,
|
||||
hover = false
|
||||
}: CardProps) {
|
||||
const layoutConfig = { ...defaultLayoutConfig, ...config };
|
||||
|
||||
const paddingClasses = {
|
||||
none: '',
|
||||
sm: 'p-3',
|
||||
md: 'p-4 sm:p-6',
|
||||
lg: 'p-6 sm:p-8',
|
||||
};
|
||||
|
||||
const shadowClasses = {
|
||||
none: '',
|
||||
sm: 'shadow-sm',
|
||||
md: 'shadow-md',
|
||||
lg: 'shadow-lg',
|
||||
xl: 'shadow-xl',
|
||||
};
|
||||
|
||||
const roundedClasses = {
|
||||
none: '',
|
||||
sm: 'rounded-sm',
|
||||
md: 'rounded-md',
|
||||
lg: 'rounded-lg',
|
||||
xl: 'rounded-xl',
|
||||
};
|
||||
|
||||
const borderClass = border ? 'border border-gray-200' : '';
|
||||
const hoverClass = hover ? 'hover:shadow-lg transition-shadow duration-200' : '';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`bg-white ${paddingClasses[padding]} ${shadowClasses[shadow]} ${roundedClasses[rounded]} ${borderClass} ${hoverClass} ${className}`}
|
||||
dir={layoutConfig.direction}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface CardHeaderProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
config?: Partial<LayoutConfig>;
|
||||
}
|
||||
|
||||
export function CardHeader({ children, className = '', config = {} }: CardHeaderProps) {
|
||||
const layoutConfig = { ...defaultLayoutConfig, ...config };
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`border-b border-gray-200 pb-4 mb-4 ${className}`}
|
||||
dir={layoutConfig.direction}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface CardBodyProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
config?: Partial<LayoutConfig>;
|
||||
}
|
||||
|
||||
export function CardBody({ children, className = '', config = {} }: CardBodyProps) {
|
||||
const layoutConfig = { ...defaultLayoutConfig, ...config };
|
||||
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
dir={layoutConfig.direction}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface CardFooterProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
config?: Partial<LayoutConfig>;
|
||||
}
|
||||
|
||||
export function CardFooter({ children, className = '', config = {} }: CardFooterProps) {
|
||||
const layoutConfig = { ...defaultLayoutConfig, ...config };
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`border-t border-gray-200 pt-4 mt-4 ${className}`}
|
||||
dir={layoutConfig.direction}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
422
app/components/ui/DataTable.tsx
Normal file
422
app/components/ui/DataTable.tsx
Normal file
@@ -0,0 +1,422 @@
|
||||
import { ReactNode, memo, useState, useMemo } from 'react';
|
||||
import { Text } from './Text';
|
||||
import { Button } from './Button';
|
||||
import { Input } from './Input';
|
||||
import { Select } from './Select';
|
||||
import { defaultLayoutConfig, type LayoutConfig } from '~/lib/layout-utils';
|
||||
|
||||
interface Column<T> {
|
||||
key: keyof T | string;
|
||||
header: string;
|
||||
render?: (item: T) => ReactNode;
|
||||
sortable?: boolean;
|
||||
filterable?: boolean;
|
||||
filterType?: 'text' | 'select' | 'date' | 'number';
|
||||
filterOptions?: { value: string; label: string }[];
|
||||
className?: string;
|
||||
width?: string;
|
||||
}
|
||||
|
||||
interface FilterState {
|
||||
[key: string]: string;
|
||||
}
|
||||
|
||||
interface DataTableProps<T> {
|
||||
data: T[];
|
||||
columns: Column<T>[];
|
||||
loading?: boolean;
|
||||
emptyMessage?: string;
|
||||
className?: string;
|
||||
config?: Partial<LayoutConfig>;
|
||||
onSort?: (key: string, direction: 'asc' | 'desc') => void;
|
||||
sortKey?: string;
|
||||
sortDirection?: 'asc' | 'desc';
|
||||
searchable?: boolean;
|
||||
searchPlaceholder?: string;
|
||||
filterable?: boolean;
|
||||
pagination?: {
|
||||
enabled: boolean;
|
||||
pageSize?: number;
|
||||
currentPage?: number;
|
||||
totalItems?: number;
|
||||
onPageChange?: (page: number) => void;
|
||||
};
|
||||
actions?: {
|
||||
label: string;
|
||||
render: (item: T) => ReactNode;
|
||||
};
|
||||
}
|
||||
|
||||
export const DataTable = memo(function DataTable<T extends Record<string, any>>({
|
||||
data,
|
||||
columns,
|
||||
loading = false,
|
||||
emptyMessage = "لا توجد بيانات",
|
||||
className = '',
|
||||
config = {},
|
||||
onSort,
|
||||
sortKey,
|
||||
sortDirection,
|
||||
searchable = false,
|
||||
searchPlaceholder = "البحث...",
|
||||
filterable = false,
|
||||
pagination,
|
||||
actions,
|
||||
}: DataTableProps<T>) {
|
||||
const layoutConfig = { ...defaultLayoutConfig, ...config };
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [filters, setFilters] = useState<FilterState>({});
|
||||
|
||||
const handleSort = (key: string) => {
|
||||
if (!onSort) return;
|
||||
|
||||
const newDirection = sortKey === key && sortDirection === 'asc' ? 'desc' : 'asc';
|
||||
onSort(key, newDirection);
|
||||
};
|
||||
|
||||
const handleFilterChange = (columnKey: string, value: string) => {
|
||||
setFilters(prev => ({
|
||||
...prev,
|
||||
[columnKey]: value,
|
||||
}));
|
||||
};
|
||||
|
||||
// Filter and search data
|
||||
const filteredData = useMemo(() => {
|
||||
let result = [...data];
|
||||
|
||||
// Apply search
|
||||
if (searchable && searchTerm) {
|
||||
result = result.filter(item => {
|
||||
return columns.some(column => {
|
||||
const value = item[column.key];
|
||||
if (value == null) return false;
|
||||
return String(value).toLowerCase().includes(searchTerm.toLowerCase());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Apply column filters
|
||||
if (filterable) {
|
||||
Object.entries(filters).forEach(([columnKey, filterValue]) => {
|
||||
if (filterValue) {
|
||||
result = result.filter(item => {
|
||||
const value = item[columnKey];
|
||||
if (value == null) return false;
|
||||
return String(value).toLowerCase().includes(filterValue.toLowerCase());
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [data, searchTerm, filters, columns, searchable, filterable]);
|
||||
|
||||
// Paginate data
|
||||
const paginatedData = useMemo(() => {
|
||||
if (!pagination?.enabled) return filteredData;
|
||||
|
||||
const pageSize = pagination.pageSize || 10;
|
||||
const currentPage = pagination.currentPage || 1;
|
||||
const startIndex = (currentPage - 1) * pageSize;
|
||||
const endIndex = startIndex + pageSize;
|
||||
|
||||
return filteredData.slice(startIndex, endIndex);
|
||||
}, [filteredData, pagination]);
|
||||
|
||||
const totalPages = pagination?.enabled
|
||||
? Math.ceil(filteredData.length / (pagination.pageSize || 10))
|
||||
: 1;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className={`bg-white rounded-lg border border-gray-200 ${className}`} dir={layoutConfig.direction}>
|
||||
<div className="p-8 text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
||||
<Text color="secondary">جاري التحميل...</Text>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
<div className={`bg-white rounded-lg border border-gray-200 ${className}`} dir={layoutConfig.direction}>
|
||||
<div className="p-8 text-center">
|
||||
<svg
|
||||
className="mx-auto h-12 w-12 text-gray-400 mb-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
<Text size="lg" weight="medium" className="mb-2">
|
||||
لا توجد بيانات
|
||||
</Text>
|
||||
<Text color="secondary">{emptyMessage}</Text>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`bg-white rounded-lg border border-gray-200 overflow-hidden ${className}`} dir={layoutConfig.direction}>
|
||||
{/* Search and Filters */}
|
||||
{(searchable || filterable) && (
|
||||
<div className="p-4 border-b border-gray-200 bg-gray-50">
|
||||
<div className="flex flex-col space-y-4">
|
||||
{/* Search */}
|
||||
{searchable && (
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
placeholder={searchPlaceholder}
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
startIcon={
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Column Filters */}
|
||||
{filterable && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{columns
|
||||
.filter(column => column.filterable)
|
||||
.map(column => (
|
||||
<div key={`filter-${column.key}`}>
|
||||
{column.filterType === 'select' && column.filterOptions ? (
|
||||
<Select
|
||||
placeholder={`تصفية ${column.header}`}
|
||||
value={filters[column.key as string] || ''}
|
||||
onChange={(e) => handleFilterChange(column.key as string, e.target.value)}
|
||||
options={[
|
||||
{ value: '', label: `جميع ${column.header}` },
|
||||
...column.filterOptions
|
||||
]}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
placeholder={`تصفية ${column.header}`}
|
||||
value={filters[column.key as string] || ''}
|
||||
onChange={(e) => handleFilterChange(column.key as string, e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Table */}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
{columns.map((column) => (
|
||||
<th
|
||||
key={`header-${column.key}`}
|
||||
className={`px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider ${column.className || ''}`}
|
||||
style={column.width ? { width: column.width } : undefined}
|
||||
>
|
||||
{column.sortable && onSort ? (
|
||||
<button
|
||||
onClick={() => handleSort(column.key as string)}
|
||||
className="group inline-flex items-center space-x-1 space-x-reverse hover:text-gray-700"
|
||||
>
|
||||
<span>{column.header}</span>
|
||||
<span className="ml-2 flex-none rounded text-gray-400 group-hover:text-gray-500">
|
||||
{sortKey === column.key ? (
|
||||
sortDirection === 'asc' ? (
|
||||
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M14.707 12.707a1 1 0 01-1.414 0L10 9.414l-3.293 3.293a1 1 0 01-1.414-1.414l4-4a1 1 0 011.414 0l4 4a1 1 0 010 1.414z" clipRule="evenodd" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clipRule="evenodd" />
|
||||
</svg>
|
||||
)
|
||||
) : (
|
||||
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M5 12a1 1 0 102 0V6.414l1.293 1.293a1 1 0 001.414-1.414l-3-3a1 1 0 00-1.414 0l-3 3a1 1 0 001.414 1.414L5 6.414V12zM15 8a1 1 0 10-2 0v5.586l-1.293-1.293a1 1 0 00-1.414 1.414l3 3a1 1 0 001.414 0l3-3a1 1 0 00-1.414-1.414L15 13.586V8z" />
|
||||
</svg>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
) : (
|
||||
column.header
|
||||
)}
|
||||
</th>
|
||||
))}
|
||||
{actions && (
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
{actions.label}
|
||||
</th>
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{paginatedData.map((item, rowIndex) => {
|
||||
// Use item.id if available, otherwise fall back to rowIndex
|
||||
const rowKey = item.id ? `row-${item.id}` : `row-${rowIndex}`;
|
||||
return (
|
||||
<tr key={rowKey} className="hover:bg-gray-50">
|
||||
{columns.map((column) => (
|
||||
<td
|
||||
key={`${rowKey}-${column.key}`}
|
||||
className={`px-6 py-4 whitespace-nowrap text-sm text-gray-900 ${column.className || ''}`}
|
||||
>
|
||||
{column.render
|
||||
? column.render(item)
|
||||
: String(item[column.key] || '')
|
||||
}
|
||||
</td>
|
||||
))}
|
||||
{actions && (
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{actions.render(item)}
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{pagination?.enabled && totalPages > 1 && (
|
||||
<div className="px-4 py-3 border-t border-gray-200 bg-gray-50">
|
||||
<Pagination
|
||||
currentPage={pagination.currentPage || 1}
|
||||
totalPages={totalPages}
|
||||
onPageChange={pagination.onPageChange || (() => {})}
|
||||
config={config}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Results Summary */}
|
||||
{(searchable || filterable || pagination?.enabled) && (
|
||||
<div className="px-4 py-2 border-t border-gray-200 bg-gray-50">
|
||||
<Text size="sm" color="secondary">
|
||||
عرض {paginatedData.length} من {filteredData.length}
|
||||
{filteredData.length !== data.length && ` (مفلتر من ${data.length})`}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}) as <T extends Record<string, any>>(props: DataTableProps<T>) => JSX.Element;
|
||||
|
||||
interface PaginationProps {
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
onPageChange: (page: number) => void;
|
||||
className?: string;
|
||||
config?: Partial<LayoutConfig>;
|
||||
}
|
||||
|
||||
export const Pagination = memo(function Pagination({
|
||||
currentPage,
|
||||
totalPages,
|
||||
onPageChange,
|
||||
className = '',
|
||||
config = {},
|
||||
}: PaginationProps) {
|
||||
const layoutConfig = { ...defaultLayoutConfig, ...config };
|
||||
|
||||
if (totalPages <= 1) return null;
|
||||
|
||||
const getPageNumbers = () => {
|
||||
const pages = [];
|
||||
const maxVisible = 5;
|
||||
|
||||
if (totalPages <= maxVisible) {
|
||||
for (let i = 1; i <= totalPages; i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
} else {
|
||||
if (currentPage <= 3) {
|
||||
for (let i = 1; i <= 4; i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
pages.push('...');
|
||||
pages.push(totalPages);
|
||||
} else if (currentPage >= totalPages - 2) {
|
||||
pages.push(1);
|
||||
pages.push('...');
|
||||
for (let i = totalPages - 3; i <= totalPages; i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
} else {
|
||||
pages.push(1);
|
||||
pages.push('...');
|
||||
for (let i = currentPage - 1; i <= currentPage + 1; i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
pages.push('...');
|
||||
pages.push(totalPages);
|
||||
}
|
||||
}
|
||||
|
||||
return pages;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`flex items-center justify-between ${className}`} dir={layoutConfig.direction}>
|
||||
<div className="flex items-center space-x-2 space-x-reverse">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onPageChange(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
>
|
||||
السابق
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center space-x-1 space-x-reverse">
|
||||
{getPageNumbers().map((page, index) => (
|
||||
<div key={index}>
|
||||
{page === '...' ? (
|
||||
<span className="px-3 py-2 text-gray-500">...</span>
|
||||
) : (
|
||||
<Button
|
||||
variant={currentPage === page ? "primary" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => onPageChange(page as number)}
|
||||
>
|
||||
{page}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onPageChange(currentPage + 1)}
|
||||
disabled={currentPage === totalPages}
|
||||
>
|
||||
التالي
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Text color="secondary" size="sm">
|
||||
صفحة {currentPage} من {totalPages}
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
233
app/components/ui/Form.tsx
Normal file
233
app/components/ui/Form.tsx
Normal file
@@ -0,0 +1,233 @@
|
||||
import { ReactNode, FormHTMLAttributes } from 'react';
|
||||
import { Form as RemixForm } from '@remix-run/react';
|
||||
import { defaultLayoutConfig, type LayoutConfig } from '~/lib/layout-utils';
|
||||
import { Button } from './Button';
|
||||
import { Text } from './Text';
|
||||
|
||||
interface FormProps extends Omit<FormHTMLAttributes<HTMLFormElement>, 'className'> {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
config?: Partial<LayoutConfig>;
|
||||
title?: string;
|
||||
description?: string;
|
||||
loading?: boolean;
|
||||
error?: string;
|
||||
success?: string;
|
||||
actions?: ReactNode;
|
||||
spacing?: 'sm' | 'md' | 'lg';
|
||||
}
|
||||
|
||||
export function Form({
|
||||
children,
|
||||
className = '',
|
||||
config = {},
|
||||
title,
|
||||
description,
|
||||
loading = false,
|
||||
error,
|
||||
success,
|
||||
actions,
|
||||
spacing = 'md',
|
||||
...props
|
||||
}: FormProps) {
|
||||
const layoutConfig = { ...defaultLayoutConfig, ...config };
|
||||
|
||||
const spacingClasses = {
|
||||
sm: 'space-y-4',
|
||||
md: 'space-y-6',
|
||||
lg: 'space-y-8',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`${className}`} dir={layoutConfig.direction}>
|
||||
{/* Form Header */}
|
||||
{(title || description) && (
|
||||
<div className="mb-6">
|
||||
{title && (
|
||||
<Text as="h2" size="xl" weight="semibold" className="mb-2">
|
||||
{title}
|
||||
</Text>
|
||||
)}
|
||||
{description && (
|
||||
<Text color="secondary">
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Success Message */}
|
||||
{success && (
|
||||
<div className="mb-6 p-4 bg-green-50 border border-green-200 rounded-lg">
|
||||
<div className="flex items-center">
|
||||
<svg className="h-5 w-5 text-green-400 ml-2" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<Text color="success" size="sm">
|
||||
{success}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg">
|
||||
<div className="flex items-center">
|
||||
<svg className="h-5 w-5 text-red-400 ml-2" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<Text color="error" size="sm">
|
||||
{error}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Form Content */}
|
||||
<RemixForm className={spacingClasses[spacing]} {...props}>
|
||||
{children}
|
||||
|
||||
{/* Form Actions */}
|
||||
{actions && (
|
||||
<div className="pt-4 border-t border-gray-200">
|
||||
{actions}
|
||||
</div>
|
||||
)}
|
||||
</RemixForm>
|
||||
|
||||
{/* Loading Overlay */}
|
||||
{loading && (
|
||||
<div className="absolute inset-0 bg-white bg-opacity-75 flex items-center justify-center rounded-lg">
|
||||
<div className="flex items-center space-x-2 space-x-reverse">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600"></div>
|
||||
<Text color="secondary">جاري المعالجة...</Text>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Form Actions Component
|
||||
interface FormActionsProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
config?: Partial<LayoutConfig>;
|
||||
justify?: 'start' | 'center' | 'end' | 'between';
|
||||
spacing?: 'sm' | 'md' | 'lg';
|
||||
}
|
||||
|
||||
export function FormActions({
|
||||
children,
|
||||
className = '',
|
||||
config = {},
|
||||
justify = 'end',
|
||||
spacing = 'md',
|
||||
}: FormActionsProps) {
|
||||
const layoutConfig = { ...defaultLayoutConfig, ...config };
|
||||
|
||||
const justifyClasses = {
|
||||
start: 'justify-start',
|
||||
center: 'justify-center',
|
||||
end: 'justify-end',
|
||||
between: 'justify-between',
|
||||
};
|
||||
|
||||
const spacingClasses = {
|
||||
sm: 'space-x-2 space-x-reverse',
|
||||
md: 'space-x-4 space-x-reverse',
|
||||
lg: 'space-x-6 space-x-reverse',
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex ${justifyClasses[justify]} ${spacingClasses[spacing]} ${className}`}
|
||||
dir={layoutConfig.direction}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Form Section Component
|
||||
interface FormSectionProps {
|
||||
children: ReactNode;
|
||||
title?: string;
|
||||
description?: string;
|
||||
className?: string;
|
||||
config?: Partial<LayoutConfig>;
|
||||
}
|
||||
|
||||
export function FormSection({
|
||||
children,
|
||||
title,
|
||||
description,
|
||||
className = '',
|
||||
config = {},
|
||||
}: FormSectionProps) {
|
||||
const layoutConfig = { ...defaultLayoutConfig, ...config };
|
||||
|
||||
return (
|
||||
<div className={`${className}`} dir={layoutConfig.direction}>
|
||||
{(title || description) && (
|
||||
<div className="mb-4">
|
||||
{title && (
|
||||
<Text as="h3" size="lg" weight="medium" className="mb-1">
|
||||
{title}
|
||||
</Text>
|
||||
)}
|
||||
{description && (
|
||||
<Text color="secondary" size="sm">
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-4">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Form Grid Component
|
||||
interface FormGridProps {
|
||||
children: ReactNode;
|
||||
columns?: 1 | 2 | 3 | 4;
|
||||
className?: string;
|
||||
config?: Partial<LayoutConfig>;
|
||||
gap?: 'sm' | 'md' | 'lg';
|
||||
}
|
||||
|
||||
export function FormGrid({
|
||||
children,
|
||||
columns = 2,
|
||||
className = '',
|
||||
config = {},
|
||||
gap = 'md',
|
||||
}: FormGridProps) {
|
||||
const layoutConfig = { ...defaultLayoutConfig, ...config };
|
||||
|
||||
const columnClasses = {
|
||||
1: 'grid-cols-1',
|
||||
2: 'grid-cols-1 md:grid-cols-2',
|
||||
3: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3',
|
||||
4: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-4',
|
||||
};
|
||||
|
||||
const gapClasses = {
|
||||
sm: 'gap-4',
|
||||
md: 'gap-6',
|
||||
lg: 'gap-8',
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`grid ${columnClasses[columns]} ${gapClasses[gap]} ${className}`}
|
||||
dir={layoutConfig.direction}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
54
app/components/ui/FormField.tsx
Normal file
54
app/components/ui/FormField.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { defaultLayoutConfig, type LayoutConfig } from '~/lib/layout-utils';
|
||||
|
||||
interface FormFieldProps {
|
||||
children: ReactNode;
|
||||
label?: string;
|
||||
error?: string;
|
||||
helperText?: string;
|
||||
required?: boolean;
|
||||
className?: string;
|
||||
config?: Partial<LayoutConfig>;
|
||||
htmlFor?: string;
|
||||
}
|
||||
|
||||
export function FormField({
|
||||
children,
|
||||
label,
|
||||
error,
|
||||
helperText,
|
||||
required = false,
|
||||
className = '',
|
||||
config = {},
|
||||
htmlFor,
|
||||
}: FormFieldProps) {
|
||||
const layoutConfig = { ...defaultLayoutConfig, ...config };
|
||||
|
||||
return (
|
||||
<div className={`${className}`} dir={layoutConfig.direction}>
|
||||
{label && (
|
||||
<label
|
||||
htmlFor={htmlFor}
|
||||
className={`block text-sm font-medium mb-2 ${error ? 'text-red-700' : 'text-gray-700'} ${layoutConfig.direction === 'rtl' ? 'text-right' : 'text-left'}`}
|
||||
>
|
||||
{label}
|
||||
{required && <span className="text-red-500 mr-1">*</span>}
|
||||
</label>
|
||||
)}
|
||||
|
||||
{children}
|
||||
|
||||
{error && (
|
||||
<p className={`mt-2 text-sm text-red-600 ${layoutConfig.direction === 'rtl' ? 'text-right' : 'text-left'}`}>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{helperText && !error && (
|
||||
<p className={`mt-2 text-sm text-gray-500 ${layoutConfig.direction === 'rtl' ? 'text-right' : 'text-left'}`}>
|
||||
{helperText}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
86
app/components/ui/Input.tsx
Normal file
86
app/components/ui/Input.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import { InputHTMLAttributes, forwardRef, useId } from 'react';
|
||||
import { getFormInputClasses, defaultLayoutConfig, type LayoutConfig } from '~/lib/layout-utils';
|
||||
|
||||
interface InputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'className'> {
|
||||
className?: string;
|
||||
config?: Partial<LayoutConfig>;
|
||||
label?: string;
|
||||
error?: string;
|
||||
helperText?: string;
|
||||
fullWidth?: boolean;
|
||||
startIcon?: React.ReactNode;
|
||||
endIcon?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const Input = forwardRef<HTMLInputElement, InputProps>(({
|
||||
className = '',
|
||||
config = {},
|
||||
label,
|
||||
error,
|
||||
helperText,
|
||||
fullWidth = true,
|
||||
startIcon,
|
||||
endIcon,
|
||||
id,
|
||||
...props
|
||||
}, ref) => {
|
||||
const layoutConfig = { ...defaultLayoutConfig, ...config };
|
||||
const inputClasses = getFormInputClasses(!!error);
|
||||
|
||||
const fullWidthClass = fullWidth ? 'w-full' : '';
|
||||
const hasIconsClass = (startIcon || endIcon) ? 'relative' : '';
|
||||
|
||||
const generatedId = useId();
|
||||
const inputId = id || generatedId;
|
||||
|
||||
return (
|
||||
<div className={`${fullWidthClass} ${className}`} dir={layoutConfig.direction}>
|
||||
{label && (
|
||||
<label
|
||||
htmlFor={inputId}
|
||||
className={`block text-sm font-medium mb-2 ${error ? 'text-red-700' : 'text-gray-700'} ${layoutConfig.direction === 'rtl' ? 'text-right' : 'text-left'}`}
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
|
||||
<div className={`relative ${hasIconsClass}`}>
|
||||
{startIcon && (
|
||||
<div className={`absolute inset-y-0 ${layoutConfig.direction === 'rtl' ? 'right-0 pr-3' : 'left-0 pl-3'} flex items-center pointer-events-none`}>
|
||||
<span className="text-gray-400 sm:text-sm">
|
||||
{startIcon}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<input
|
||||
ref={ref}
|
||||
id={inputId}
|
||||
className={`${inputClasses} ${startIcon ? (layoutConfig.direction === 'rtl' ? 'pr-10' : 'pl-10') : ''} ${endIcon ? (layoutConfig.direction === 'rtl' ? 'pl-10' : 'pr-10') : ''}`}
|
||||
dir={layoutConfig.direction}
|
||||
{...props}
|
||||
/>
|
||||
|
||||
{endIcon && (
|
||||
<div className={`absolute inset-y-0 ${layoutConfig.direction === 'rtl' ? 'left-0 pl-3' : 'right-0 pr-3'} flex items-center pointer-events-none`}>
|
||||
<span className="text-gray-400 sm:text-sm">
|
||||
{endIcon}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className={`mt-2 text-sm text-red-600 ${layoutConfig.direction === 'rtl' ? 'text-right' : 'text-left'}`}>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{helperText && !error && (
|
||||
<p className={`mt-2 text-sm text-gray-500 ${layoutConfig.direction === 'rtl' ? 'text-right' : 'text-left'}`}>
|
||||
{helperText}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
220
app/components/ui/Modal.tsx
Normal file
220
app/components/ui/Modal.tsx
Normal file
@@ -0,0 +1,220 @@
|
||||
import { ReactNode, useEffect, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Button } from './Button';
|
||||
import { Text } from './Text';
|
||||
import { defaultLayoutConfig, type LayoutConfig } from '~/lib/layout-utils';
|
||||
|
||||
interface ModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
size?: 'sm' | 'md' | 'lg' | 'xl';
|
||||
className?: string;
|
||||
config?: Partial<LayoutConfig>;
|
||||
showCloseButton?: boolean;
|
||||
}
|
||||
|
||||
export function Modal({
|
||||
isOpen,
|
||||
onClose,
|
||||
title,
|
||||
children,
|
||||
size = 'md',
|
||||
className = '',
|
||||
config = {},
|
||||
showCloseButton = true,
|
||||
}: ModalProps) {
|
||||
const layoutConfig = { ...defaultLayoutConfig, ...config };
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIsMounted(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
} else {
|
||||
document.body.style.overflow = 'unset';
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = 'unset';
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleEscape);
|
||||
};
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
if (!isOpen || !isMounted) return null;
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'max-w-md',
|
||||
md: 'max-w-lg',
|
||||
lg: 'max-w-2xl',
|
||||
xl: 'max-w-4xl',
|
||||
};
|
||||
|
||||
const modalContent = (
|
||||
<div className="fixed inset-0 z-50 overflow-y-auto" dir={layoutConfig.direction}>
|
||||
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Modal panel */}
|
||||
<div
|
||||
className={`relative transform overflow-hidden rounded-lg bg-white text-right shadow-xl transition-all sm:my-8 sm:w-full ${sizeClasses[size]} ${className}`}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<Text as="h3" size="lg" weight="medium">
|
||||
{title}
|
||||
</Text>
|
||||
{showCloseButton && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
|
||||
>
|
||||
<span className="sr-only">إغلاق</span>
|
||||
<svg className="h-6 w-6" 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>
|
||||
|
||||
{/* Content */}
|
||||
<div>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return createPortal(modalContent, document.body);
|
||||
}
|
||||
|
||||
interface ConfirmModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
title: string;
|
||||
message: string;
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
variant?: 'danger' | 'warning' | 'info';
|
||||
config?: Partial<LayoutConfig>;
|
||||
}
|
||||
|
||||
export function ConfirmModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirm,
|
||||
title,
|
||||
message,
|
||||
confirmText = "تأكيد",
|
||||
cancelText = "إلغاء",
|
||||
variant = 'info',
|
||||
config = {},
|
||||
}: ConfirmModalProps) {
|
||||
const layoutConfig = { ...defaultLayoutConfig, ...config };
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIsMounted(true);
|
||||
}, []);
|
||||
|
||||
const handleConfirm = () => {
|
||||
onConfirm();
|
||||
onClose();
|
||||
};
|
||||
|
||||
const getIcon = () => {
|
||||
switch (variant) {
|
||||
case 'danger':
|
||||
return (
|
||||
<div className="mx-auto flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-red-100 sm:mx-0 sm:h-10 sm:w-10">
|
||||
<svg className="h-6 w-6 text-red-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z" />
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
case 'warning':
|
||||
return (
|
||||
<div className="mx-auto flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-yellow-100 sm:mx-0 sm:h-10 sm:w-10">
|
||||
<svg className="h-6 w-6 text-yellow-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z" />
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<div className="mx-auto flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-blue-100 sm:mx-0 sm:h-10 sm:w-10">
|
||||
<svg className="h-6 w-6 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title=""
|
||||
size="sm"
|
||||
showCloseButton={false}
|
||||
config={config}
|
||||
>
|
||||
<div className="sm:flex sm:items-start" dir={layoutConfig.direction}>
|
||||
{getIcon()}
|
||||
<div className="mt-3 text-center sm:mt-0 sm:mr-4 sm:text-right">
|
||||
<Text as="h3" size="lg" weight="medium" className="mb-2">
|
||||
{title}
|
||||
</Text>
|
||||
<Text color="secondary" className="mb-4">
|
||||
{message}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 sm:mt-4 sm:flex w-full sm:flex-row space-x-2">
|
||||
<Button
|
||||
onClick={handleConfirm}
|
||||
variant={variant === 'danger' ? 'danger' : 'primary'}
|
||||
className="w-full sm:w-auto sm:ml-3"
|
||||
>
|
||||
{confirmText}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onClose}
|
||||
variant="outline"
|
||||
className="mt-3 w-full sm:mt-0 sm:w-auto"
|
||||
>
|
||||
{cancelText}
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
171
app/components/ui/MultiSelect.tsx
Normal file
171
app/components/ui/MultiSelect.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { Text } from "./Text";
|
||||
|
||||
interface Option {
|
||||
value: string | number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface MultiSelectProps {
|
||||
name?: string;
|
||||
label?: string;
|
||||
options: Option[];
|
||||
value: (string | number)[];
|
||||
onChange: (values: (string | number)[]) => void;
|
||||
placeholder?: string;
|
||||
error?: string;
|
||||
required?: boolean;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function MultiSelect({
|
||||
name,
|
||||
label,
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
placeholder = "اختر العناصر...",
|
||||
error,
|
||||
required,
|
||||
disabled,
|
||||
className = ""
|
||||
}: MultiSelectProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const handleToggleOption = (optionValue: string | number) => {
|
||||
if (disabled) return;
|
||||
|
||||
const newValue = value.includes(optionValue)
|
||||
? value.filter(v => v !== optionValue)
|
||||
: [...value, optionValue];
|
||||
|
||||
onChange(newValue);
|
||||
};
|
||||
|
||||
const handleRemoveItem = (optionValue: string | number) => {
|
||||
if (disabled) return;
|
||||
onChange(value.filter(v => v !== optionValue));
|
||||
};
|
||||
|
||||
const selectedOptions = options.filter(option => value.includes(option.value));
|
||||
const displayText = selectedOptions.length > 0
|
||||
? `تم اختيار ${selectedOptions.length} عنصر`
|
||||
: placeholder;
|
||||
|
||||
return (
|
||||
<div className={`relative ${className}`} ref={containerRef}>
|
||||
{label && (
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
{label}
|
||||
{required && <span className="text-red-500 mr-1">*</span>}
|
||||
</label>
|
||||
)}
|
||||
|
||||
{/* Hidden input for form submission */}
|
||||
{name && (
|
||||
<input
|
||||
type="hidden"
|
||||
name={name}
|
||||
value={JSON.stringify(value)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Selected items display */}
|
||||
{selectedOptions.length > 0 && (
|
||||
<div className="mb-2 flex flex-wrap gap-2">
|
||||
{selectedOptions.map((option) => (
|
||||
<span
|
||||
key={option.value}
|
||||
className="inline-flex items-center px-2 py-1 text-xs font-medium bg-blue-100 text-blue-800 rounded-full"
|
||||
>
|
||||
{option.label}
|
||||
{!disabled && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveItem(option.value)}
|
||||
className="mr-1 text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dropdown trigger */}
|
||||
<div
|
||||
className={`w-full px-3 py-2 border rounded-lg shadow-sm cursor-pointer focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white ${
|
||||
error
|
||||
? 'border-red-300 focus:ring-red-500 focus:border-red-500'
|
||||
: 'border-gray-300'
|
||||
} ${disabled ? 'bg-gray-50 cursor-not-allowed' : ''}`}
|
||||
onClick={() => !disabled && setIsOpen(!isOpen)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className={selectedOptions.length > 0 ? 'text-gray-900' : 'text-gray-500'}>
|
||||
{displayText}
|
||||
</span>
|
||||
<svg
|
||||
className={`h-5 w-5 text-gray-400 transition-transform ${isOpen ? 'rotate-180' : ''}`}
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path fillRule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dropdown menu */}
|
||||
{isOpen && (
|
||||
<div className="absolute z-10 w-full mt-1 bg-white border border-gray-300 rounded-lg shadow-lg max-h-60 overflow-auto">
|
||||
{options.length === 0 ? (
|
||||
<div className="px-3 py-2 text-gray-500 text-sm">
|
||||
لا توجد خيارات متاحة
|
||||
</div>
|
||||
) : (
|
||||
options.map((option) => {
|
||||
const isSelected = value.includes(option.value);
|
||||
return (
|
||||
<div
|
||||
key={option.value}
|
||||
className={`px-3 py-2 cursor-pointer hover:bg-gray-50 flex items-center justify-between ${
|
||||
isSelected ? 'bg-blue-50 text-blue-700' : 'text-gray-900'
|
||||
}`}
|
||||
onClick={() => handleToggleOption(option.value)}
|
||||
>
|
||||
<span>{option.label}</span>
|
||||
{isSelected && (
|
||||
<svg className="h-4 w-4 text-blue-600" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Text size="sm" color="error" className="mt-1">
|
||||
{error}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
79
app/components/ui/SearchInput.tsx
Normal file
79
app/components/ui/SearchInput.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Input } from './Input';
|
||||
import { defaultLayoutConfig, type LayoutConfig } from '~/lib/layout-utils';
|
||||
|
||||
interface SearchInputProps {
|
||||
placeholder?: string;
|
||||
onSearch: (query: string) => void;
|
||||
debounceMs?: number;
|
||||
className?: string;
|
||||
config?: Partial<LayoutConfig>;
|
||||
initialValue?: string;
|
||||
}
|
||||
|
||||
export function SearchInput({
|
||||
placeholder = "البحث...",
|
||||
onSearch,
|
||||
debounceMs = 300,
|
||||
className = '',
|
||||
config = {},
|
||||
initialValue = '',
|
||||
}: SearchInputProps) {
|
||||
const layoutConfig = { ...defaultLayoutConfig, ...config };
|
||||
const [query, setQuery] = useState(initialValue);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
onSearch(query);
|
||||
}, debounceMs);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [query, onSearch, debounceMs]);
|
||||
|
||||
return (
|
||||
<div className={`relative ${className}`} dir={layoutConfig.direction}>
|
||||
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
|
||||
<svg
|
||||
className="h-5 w-5 text-gray-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={placeholder}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
className="pr-10"
|
||||
/>
|
||||
{query && (
|
||||
<button
|
||||
onClick={() => setQuery('')}
|
||||
className="absolute inset-y-0 left-0 pl-3 flex items-center"
|
||||
>
|
||||
<svg
|
||||
className="h-5 w-5 text-gray-400 hover:text-gray-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
97
app/components/ui/Select.tsx
Normal file
97
app/components/ui/Select.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import { SelectHTMLAttributes, forwardRef } from 'react';
|
||||
import { getFormInputClasses, defaultLayoutConfig, type LayoutConfig } from '~/lib/layout-utils';
|
||||
|
||||
interface SelectOption {
|
||||
value: string;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface SelectProps extends Omit<SelectHTMLAttributes<HTMLSelectElement>, 'className'> {
|
||||
className?: string;
|
||||
config?: Partial<LayoutConfig>;
|
||||
label?: string;
|
||||
error?: string;
|
||||
helperText?: string;
|
||||
fullWidth?: boolean;
|
||||
options: SelectOption[];
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export const Select = forwardRef<HTMLSelectElement, SelectProps>(({
|
||||
className = '',
|
||||
config = {},
|
||||
label,
|
||||
error,
|
||||
helperText,
|
||||
fullWidth = true,
|
||||
options,
|
||||
placeholder,
|
||||
id,
|
||||
...props
|
||||
}, ref) => {
|
||||
const layoutConfig = { ...defaultLayoutConfig, ...config };
|
||||
const inputClasses = getFormInputClasses(!!error);
|
||||
|
||||
const fullWidthClass = fullWidth ? 'w-full' : '';
|
||||
const inputId = id || `select-${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
return (
|
||||
<div className={`${fullWidthClass} ${className}`} dir={layoutConfig.direction}>
|
||||
{label && (
|
||||
<label
|
||||
htmlFor={inputId}
|
||||
className={`block text-sm font-medium mb-2 ${error ? 'text-red-700' : 'text-gray-700'} ${layoutConfig.direction === 'rtl' ? 'text-right' : 'text-left'}`}
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
|
||||
<div className="relative">
|
||||
<select
|
||||
ref={ref}
|
||||
id={inputId}
|
||||
className={`${inputClasses} appearance-none bg-white`}
|
||||
dir={layoutConfig.direction}
|
||||
{...props}
|
||||
>
|
||||
{placeholder && (
|
||||
<option value="" disabled>
|
||||
{placeholder}
|
||||
</option>
|
||||
)}
|
||||
{options.map((option) => (
|
||||
<option
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
disabled={option.disabled}
|
||||
>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{/* Custom dropdown arrow */}
|
||||
<div className={`absolute inset-y-0 ${layoutConfig.direction === 'rtl' ? 'left-0 pl-3' : 'right-0 pr-3'} flex items-center pointer-events-none`}>
|
||||
<svg className="h-5 w-5 text-gray-400" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className={`mt-2 text-sm text-red-600 ${layoutConfig.direction === 'rtl' ? 'text-right' : 'text-left'}`}>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{helperText && !error && (
|
||||
<p className={`mt-2 text-sm text-gray-500 ${layoutConfig.direction === 'rtl' ? 'text-right' : 'text-left'}`}>
|
||||
{helperText}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
Select.displayName = 'Select';
|
||||
73
app/components/ui/Text.tsx
Normal file
73
app/components/ui/Text.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { getArabicTextClasses, defaultLayoutConfig, type LayoutConfig } from '~/lib/layout-utils';
|
||||
|
||||
interface TextProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
config?: Partial<LayoutConfig>;
|
||||
size?: 'xs' | 'sm' | 'base' | 'lg' | 'xl' | '2xl' | '3xl' | '4xl';
|
||||
weight?: 'light' | 'normal' | 'medium' | 'semibold' | 'bold';
|
||||
color?: 'primary' | 'secondary' | 'success' | 'warning' | 'error' | 'muted';
|
||||
align?: 'left' | 'center' | 'right' | 'justify';
|
||||
as?: 'p' | 'span' | 'div' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
|
||||
}
|
||||
|
||||
export function Text({
|
||||
children,
|
||||
className = '',
|
||||
config = {},
|
||||
size = 'base',
|
||||
weight = 'normal',
|
||||
color = 'primary',
|
||||
align,
|
||||
as: Component = 'p'
|
||||
}: TextProps) {
|
||||
const layoutConfig = { ...defaultLayoutConfig, ...config };
|
||||
|
||||
const sizeClasses = {
|
||||
xs: 'text-xs',
|
||||
sm: 'text-sm',
|
||||
base: 'text-base',
|
||||
lg: 'text-lg',
|
||||
xl: 'text-xl',
|
||||
'2xl': 'text-2xl',
|
||||
'3xl': 'text-3xl',
|
||||
'4xl': 'text-4xl',
|
||||
};
|
||||
|
||||
const weightClasses = {
|
||||
light: 'font-light',
|
||||
normal: 'font-normal',
|
||||
medium: 'font-medium',
|
||||
semibold: 'font-semibold',
|
||||
bold: 'font-bold',
|
||||
};
|
||||
|
||||
const colorClasses = {
|
||||
primary: 'text-gray-900 dark:text-gray-100',
|
||||
secondary: 'text-gray-600 dark:text-gray-400',
|
||||
success: 'text-green-600 dark:text-green-400',
|
||||
warning: 'text-amber-600 dark:text-amber-400',
|
||||
error: 'text-red-600 dark:text-red-400',
|
||||
muted: 'text-gray-500 dark:text-gray-500',
|
||||
};
|
||||
|
||||
const alignClasses = {
|
||||
left: layoutConfig.direction === 'rtl' ? 'text-right' : 'text-left',
|
||||
center: 'text-center',
|
||||
right: layoutConfig.direction === 'rtl' ? 'text-left' : 'text-right',
|
||||
justify: 'text-justify',
|
||||
};
|
||||
|
||||
const alignClass = align ? alignClasses[align] : (layoutConfig.direction === 'rtl' ? 'text-right' : 'text-left');
|
||||
const arabicClasses = getArabicTextClasses(size as any);
|
||||
|
||||
return (
|
||||
<Component
|
||||
className={`${arabicClasses} ${sizeClasses[size]} ${weightClasses[weight]} ${colorClasses[color]} ${alignClass} ${className}`}
|
||||
dir={layoutConfig.direction}
|
||||
>
|
||||
{children}
|
||||
</Component>
|
||||
);
|
||||
}
|
||||
72
app/components/ui/Textarea.tsx
Normal file
72
app/components/ui/Textarea.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import { TextareaHTMLAttributes, forwardRef } from 'react';
|
||||
import { getFormInputClasses, defaultLayoutConfig, type LayoutConfig } from '~/lib/layout-utils';
|
||||
|
||||
interface TextareaProps extends Omit<TextareaHTMLAttributes<HTMLTextAreaElement>, 'className'> {
|
||||
className?: string;
|
||||
config?: Partial<LayoutConfig>;
|
||||
label?: string;
|
||||
error?: string;
|
||||
helperText?: string;
|
||||
fullWidth?: boolean;
|
||||
resize?: 'none' | 'vertical' | 'horizontal' | 'both';
|
||||
}
|
||||
|
||||
export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(({
|
||||
className = '',
|
||||
config = {},
|
||||
label,
|
||||
error,
|
||||
helperText,
|
||||
fullWidth = true,
|
||||
resize = 'vertical',
|
||||
id,
|
||||
...props
|
||||
}, ref) => {
|
||||
const layoutConfig = { ...defaultLayoutConfig, ...config };
|
||||
const inputClasses = getFormInputClasses(!!error);
|
||||
|
||||
const fullWidthClass = fullWidth ? 'w-full' : '';
|
||||
const resizeClass = {
|
||||
none: 'resize-none',
|
||||
vertical: 'resize-y',
|
||||
horizontal: 'resize-x',
|
||||
both: 'resize',
|
||||
}[resize];
|
||||
|
||||
const inputId = id || `textarea-${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
return (
|
||||
<div className={`${fullWidthClass} ${className}`} dir={layoutConfig.direction}>
|
||||
{label && (
|
||||
<label
|
||||
htmlFor={inputId}
|
||||
className={`block text-sm font-medium mb-2 ${error ? 'text-red-700' : 'text-gray-700'} ${layoutConfig.direction === 'rtl' ? 'text-right' : 'text-left'}`}
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
|
||||
<textarea
|
||||
ref={ref}
|
||||
id={inputId}
|
||||
className={`${inputClasses} ${resizeClass} min-h-[80px]`}
|
||||
dir={layoutConfig.direction}
|
||||
{...props}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<p className={`mt-2 text-sm text-red-600 ${layoutConfig.direction === 'rtl' ? 'text-right' : 'text-left'}`}>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{helperText && !error && (
|
||||
<p className={`mt-2 text-sm text-gray-500 ${layoutConfig.direction === 'rtl' ? 'text-right' : 'text-left'}`}>
|
||||
{helperText}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
Textarea.displayName = 'Textarea';
|
||||
10
app/components/ui/index.ts
Normal file
10
app/components/ui/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export { Text } from './Text';
|
||||
export { Button } from './Button';
|
||||
export { Input } from './Input';
|
||||
export { AutocompleteInput } from './AutocompleteInput';
|
||||
export { Card, CardHeader, CardBody, CardFooter } from './Card';
|
||||
export { DataTable, Pagination } from './DataTable';
|
||||
export { SearchInput } from './SearchInput';
|
||||
export { Modal, ConfirmModal } from './Modal';
|
||||
export { Select } from './Select';
|
||||
export { MultiSelect } from './MultiSelect';
|
||||
Reference in New Issue
Block a user