neogestify-ui-components 2.2.1 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +803 -349
  3. package/dist/components/ElementLibraryBuilder/index.js +299 -120
  4. package/dist/components/ElementLibraryBuilder/index.js.map +1 -1
  5. package/dist/components/ElementLibraryBuilder/index.mjs +300 -121
  6. package/dist/components/ElementLibraryBuilder/index.mjs.map +1 -1
  7. package/dist/components/VenueMapEditor/index.js.map +1 -1
  8. package/dist/components/VenueMapEditor/index.mjs.map +1 -1
  9. package/dist/components/alerts/index.js +108 -51
  10. package/dist/components/alerts/index.js.map +1 -1
  11. package/dist/components/alerts/index.mjs +109 -52
  12. package/dist/components/alerts/index.mjs.map +1 -1
  13. package/dist/components/html/index.d.mts +123 -11
  14. package/dist/components/html/index.d.ts +123 -11
  15. package/dist/components/html/index.js +607 -144
  16. package/dist/components/html/index.js.map +1 -1
  17. package/dist/components/html/index.mjs +608 -145
  18. package/dist/components/html/index.mjs.map +1 -1
  19. package/dist/components/icons/index.d.mts +5 -1
  20. package/dist/components/icons/index.d.ts +5 -1
  21. package/dist/components/icons/index.js +16 -0
  22. package/dist/components/icons/index.js.map +1 -1
  23. package/dist/components/icons/index.mjs +13 -1
  24. package/dist/components/icons/index.mjs.map +1 -1
  25. package/dist/context/theme/index.js +59 -37
  26. package/dist/context/theme/index.js.map +1 -1
  27. package/dist/context/theme/index.mjs +59 -37
  28. package/dist/context/theme/index.mjs.map +1 -1
  29. package/dist/index.d.mts +1 -1
  30. package/dist/index.d.ts +1 -1
  31. package/dist/index.js +611 -144
  32. package/dist/index.js.map +1 -1
  33. package/dist/index.mjs +609 -146
  34. package/dist/index.mjs.map +1 -1
  35. package/package.json +1 -1
  36. package/src/components/html/Button.tsx +84 -38
  37. package/src/components/html/Form.tsx +33 -13
  38. package/src/components/html/Input.tsx +110 -31
  39. package/src/components/html/Loading.tsx +58 -33
  40. package/src/components/html/Modal.tsx +67 -20
  41. package/src/components/html/Select.tsx +92 -39
  42. package/src/components/html/Table.tsx +429 -38
  43. package/src/components/html/TextArea.tsx +81 -31
  44. package/src/components/icons/icons.tsx +32 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "neogestify-ui-components",
3
- "version": "2.2.1",
3
+ "version": "2.3.0",
4
4
  "description": "Biblioteca de componentes UI reutilizables con React, Tailwind y SweetAlert, con VenueMapEditor o editor de mapas basico",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.mjs",
@@ -1,72 +1,118 @@
1
1
  import { AnimateSpin } from '../icons/icons';
2
2
  import { type ButtonHTMLAttributes, type FC, type ReactNode } from 'react';
3
3
 
4
+ type ButtonVariant = 'primary' | 'secondary' | 'icon' | 'danger' | 'success' | 'outline' | 'nav' | 'custom' | 'link' | 'warning' | 'toggle' | 'ghost';
5
+ type ButtonSize = 'sm' | 'md' | 'lg';
6
+ type ButtonShape = 'rounded' | 'pill' | 'square';
7
+
4
8
  interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
5
- variant?: 'primary' | 'secondary' | 'icon' | 'danger' | 'success' | 'outline' | 'nav' | 'custom' | 'link' | 'warning' | 'toggle';
9
+ variant?: ButtonVariant;
6
10
  children: ReactNode;
7
11
  isLoading?: boolean;
8
12
  loadingText?: string;
9
13
  isActive?: boolean;
14
+ size?: ButtonSize;
15
+ leftIcon?: ReactNode;
16
+ rightIcon?: ReactNode;
17
+ fullWidth?: boolean;
18
+ shape?: ButtonShape;
10
19
  }
11
20
 
21
+ const BASE = 'transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer inline-flex items-center gap-2';
22
+
23
+ const SIZE_PAD: Record<ButtonSize, string> = {
24
+ sm: 'px-2.5 py-1.5 text-xs',
25
+ md: 'px-3 py-2 text-sm',
26
+ lg: 'px-4 py-2.5 text-base',
27
+ };
28
+
29
+ const SIZE_ICON_PAD: Record<ButtonSize, string> = {
30
+ sm: 'p-1.5',
31
+ md: 'p-2',
32
+ lg: 'p-2.5',
33
+ };
34
+
35
+ const SHAPE_CLASS: Record<ButtonShape, string> = {
36
+ rounded: 'rounded-md',
37
+ pill: 'rounded-full',
38
+ square: 'rounded-none',
39
+ };
40
+
41
+ const VARIANT_STYLE: Record<ButtonVariant, string> = {
42
+ primary: 'border border-indigo-600/20 dark:border-indigo-500/20 font-medium text-white bg-indigo-600 hover:bg-indigo-700 dark:bg-indigo-500 dark:hover:bg-indigo-600 focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:focus:ring-indigo-400 focus:ring-offset-2 focus:ring-offset-gray-50 dark:focus:ring-offset-gray-900',
43
+ secondary: 'font-medium text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-200 dark:hover:bg-gray-800 border border-gray-300 dark:border-gray-600 shadow-sm hover:shadow-md focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:focus:ring-indigo-400 focus:ring-offset-2 focus:ring-offset-gray-50 dark:focus:ring-offset-gray-900',
44
+ icon: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-200 dark:hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:focus:ring-indigo-400 focus:ring-offset-2 focus:ring-offset-gray-50 dark:focus:ring-offset-gray-900',
45
+ danger: 'border border-red-600/20 dark:border-red-500/20 font-medium text-white bg-red-600 hover:bg-red-700 dark:bg-red-500 dark:hover:bg-red-600 focus:outline-none focus:ring-2 focus:ring-red-500 dark:focus:ring-red-400 focus:ring-offset-2 focus:ring-offset-gray-50 dark:focus:ring-offset-gray-900',
46
+ success: 'border border-green-600/20 dark:border-green-500/20 font-medium text-white bg-green-600 hover:bg-green-700 dark:bg-green-500 dark:hover:bg-green-600 focus:outline-none focus:ring-2 focus:ring-green-500 dark:focus:ring-green-400 focus:ring-offset-2 focus:ring-offset-gray-50 dark:focus:ring-offset-gray-900',
47
+ outline: 'border border-gray-300 dark:border-gray-600 font-medium text-gray-700 dark:text-gray-300 bg-transparent hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:focus:ring-indigo-400 focus:ring-offset-2 focus:ring-offset-gray-50 dark:focus:ring-offset-gray-900',
48
+ nav: 'w-full px-4 py-2 text-sm font-medium hover:scale-105 text-gray-700 dark:text-gray-300 dark:hover:text-white hover:shadow-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:focus:ring-indigo-400 focus:ring-offset-2 focus:ring-offset-gray-50 dark:focus:ring-offset-gray-900',
49
+ custom: '',
50
+ link: 'text-sm text-indigo-600 dark:text-indigo-400 hover:text-indigo-700 dark:hover:text-indigo-300 focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:focus:ring-indigo-400 focus:ring-offset-2 focus:ring-offset-gray-50 dark:focus:ring-offset-gray-900 rounded-lg px-2',
51
+ warning: 'border border-yellow-600/20 dark:border-yellow-500/20 font-medium text-white bg-yellow-600 hover:bg-yellow-700 dark:bg-yellow-500 dark:hover:bg-yellow-600 focus:outline-none focus:ring-2 focus:ring-yellow-500 dark:focus:ring-yellow-400 focus:ring-offset-2 focus:ring-offset-gray-50 dark:focus:ring-offset-gray-900',
52
+ toggle: 'font-medium border-2 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 focus:ring-offset-gray-50 dark:focus:ring-offset-gray-900',
53
+ ghost: 'font-medium text-indigo-600 dark:text-indigo-400 bg-transparent border border-indigo-200 dark:border-indigo-800 hover:bg-indigo-50 dark:hover:bg-indigo-900/30 focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:focus:ring-indigo-400 focus:ring-offset-2 focus:ring-offset-gray-50 dark:focus:ring-offset-gray-900',
54
+ };
55
+
12
56
  export const Button: FC<ButtonProps> = ({
13
57
  variant = 'primary',
14
58
  children,
15
59
  isLoading = false,
16
60
  loadingText,
17
61
  isActive = false,
62
+ size = 'md',
63
+ leftIcon,
64
+ rightIcon,
65
+ fullWidth = false,
66
+ shape,
18
67
  className = '',
19
68
  disabled,
20
69
  ...props
21
70
  }) => {
22
- const baseClasses = 'transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer';
23
-
24
- const variantClasses = {
25
- primary: 'py-2 px-2 border border-indigo-600/20 dark:border-indigo-500/20 text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 dark:bg-indigo-500 dark:hover:bg-indigo-600 focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:focus:ring-indigo-400 focus:ring-offset-2 focus:ring-offset-gray-50 dark:focus:ring-offset-gray-900',
26
- secondary: 'p-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-200 dark:hover:bg-gray-800 rounded-md border border-gray-300 dark:border-gray-600 shadow-sm hover:shadow-md focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:focus:ring-indigo-400 focus:ring-offset-2 focus:ring-offset-gray-50 dark:focus:ring-offset-gray-900',
27
- icon: 'p-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-200 dark:hover:bg-gray-800 rounded-full focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:focus:ring-indigo-400 focus:ring-offset-2 focus:ring-offset-gray-50 dark:focus:ring-offset-gray-900',
28
- danger: 'py-2 px-2 border border-red-600/20 dark:border-red-500/20 text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700 dark:bg-red-500 dark:hover:bg-red-600 focus:outline-none focus:ring-2 focus:ring-red-500 dark:focus:ring-red-400 focus:ring-offset-2 focus:ring-offset-gray-50 dark:focus:ring-offset-gray-900',
29
- success: 'py-2 px-2 border border-green-600/20 dark:border-green-500/20 text-sm font-medium rounded-md text-white bg-green-600 hover:bg-green-700 dark:bg-green-500 dark:hover:bg-green-600 focus:outline-none focus:ring-2 focus:ring-green-500 dark:focus:ring-green-400 focus:ring-offset-2 focus:ring-offset-gray-50 dark:focus:ring-offset-gray-900',
30
- outline: 'py-2 px-2 border border-gray-300 dark:border-gray-600 text-sm font-medium rounded-md text-gray-700 dark:text-gray-300 bg-transparent hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:focus:ring-indigo-400 focus:ring-offset-2 focus:ring-offset-gray-50 dark:focus:ring-offset-gray-900',
31
- nav: 'w-full flex items-center px-4 py-2 text-sm font-medium rounded-md transition-all duration-200 hover:scale-105 text-gray-700 dark:text-gray-300 dark:hover:text-white hover:shadow-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:focus:ring-indigo-400 focus:ring-offset-2 focus:ring-offset-gray-50 dark:focus:ring-offset-gray-900',
32
- custom: "",
33
- link: 'text-sm text-indigo-600 dark:text-indigo-400 hover:text-indigo-700 dark:hover:text-indigo-300 transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:focus:ring-indigo-400 focus:ring-offset-2 focus:ring-offset-gray-50 dark:focus:ring-offset-gray-900 rounded-lg px-2',
34
- warning: 'py-2 px-2 border border-yellow-600/20 dark:border-yellow-500/20 text-sm font-medium rounded-md text-white bg-yellow-600 hover:bg-yellow-700 dark:bg-yellow-500 dark:hover:bg-yellow-600 focus:outline-none focus:ring-2 focus:ring-yellow-500 dark:focus:ring-yellow-400 focus:ring-offset-2 focus:ring-offset-gray-50 dark:focus:ring-offset-gray-900',
35
- toggle: 'px-2 py-2 rounded-lg font-medium transition-all duration-200 disabled:cursor-not-allowed border-2 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 focus:ring-offset-gray-50 dark:focus:ring-offset-gray-900'
36
- };
71
+ const sizeCls = variant === 'icon'
72
+ ? SIZE_ICON_PAD[size]
73
+ : variant === 'nav' || variant === 'link' || variant === 'custom'
74
+ ? ''
75
+ : SIZE_PAD[size];
37
76
 
38
- let classes = `${baseClasses} ${variantClasses[variant]} ${className}`;
77
+ const defaultShape: ButtonShape = variant === 'icon' ? 'pill' : 'rounded';
78
+ const shapeCls = variant === 'link' || variant === 'custom' ? '' : SHAPE_CLASS[shape ?? defaultShape];
39
79
 
40
- if (variant === 'nav' && isActive) {
41
- classes += ' bg-indigo-600 dark:bg-indigo-500 hover:bg-indigo-700 dark:hover:bg-indigo-600 text-white shadow-lg scale-105';
80
+ let stateCls = '';
81
+ if (variant === 'nav') {
82
+ stateCls = isActive
83
+ ? 'bg-indigo-600 dark:bg-indigo-500 hover:bg-indigo-700 dark:hover:bg-indigo-600 text-white shadow-lg scale-105'
84
+ : 'hover:bg-white dark:hover:bg-gray-700';
42
85
  }
43
-
44
- if (variant === 'nav' && !isActive) {
45
- classes += ' hover:bg-white dark:hover:bg-gray-700 hover:shadow-lg';
46
- }
47
-
48
86
  if (variant === 'toggle') {
49
- if (isActive) {
50
- classes += ' bg-indigo-600 text-white border-indigo-600/30 dark:border-indigo-500/30 hover:bg-indigo-700';
51
- } else {
52
- classes += ' bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 border-gray-300 dark:border-gray-600 hover:bg-gray-300 dark:hover:bg-gray-600 hover:border-gray-400 dark:hover:border-gray-500';
53
- }
87
+ stateCls = isActive
88
+ ? 'bg-indigo-600 text-white border-indigo-600/30 dark:border-indigo-500/30 hover:bg-indigo-700'
89
+ : 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 border-gray-300 dark:border-gray-600 hover:bg-gray-300 dark:hover:bg-gray-600';
54
90
  }
55
91
 
92
+ const classes = [
93
+ BASE,
94
+ VARIANT_STYLE[variant],
95
+ sizeCls,
96
+ shapeCls,
97
+ stateCls,
98
+ fullWidth ? 'w-full justify-center' : '',
99
+ className,
100
+ ].filter(Boolean).join(' ');
101
+
56
102
  return (
57
- <button
58
- className={classes}
59
- disabled={disabled || isLoading}
60
- {...props}
61
- >
103
+ <button className={classes} disabled={disabled || isLoading} {...props}>
62
104
  {isLoading ? (
63
105
  <>
64
- <AnimateSpin className="h-5 w-5 mr-2 inline-block text-current" />
65
- {loadingText || 'Cargando...'}
106
+ <AnimateSpin className="h-4 w-4 shrink-0 text-current" />
107
+ {loadingText ?? 'Cargando...'}
66
108
  </>
67
109
  ) : (
68
- children
110
+ <>
111
+ {leftIcon && <span className="shrink-0">{leftIcon}</span>}
112
+ {children}
113
+ {rightIcon && <span className="shrink-0">{rightIcon}</span>}
114
+ </>
69
115
  )}
70
116
  </button>
71
117
  );
72
- };
118
+ };
@@ -1,40 +1,60 @@
1
- import { type FormHTMLAttributes, type FC, type FormEvent, type ReactNode } from 'react';
1
+ import { type FormHTMLAttributes, type FC, type FormEvent, type ReactNode, type CSSProperties } from 'react';
2
2
 
3
3
  interface FormProps extends FormHTMLAttributes<HTMLFormElement> {
4
4
  children: ReactNode;
5
5
  onSubmit?: (e: FormEvent<HTMLFormElement>) => void;
6
6
  variant?: 'default' | 'modal' | 'card' | 'inline' | 'compact';
7
+ /** Number of columns for a CSS grid layout. 1 = single column, >1 = multi-column grid. */
8
+ columns?: number;
7
9
  }
8
10
 
9
- export const Form: FC<FormProps> = ({ onSubmit, children, variant = 'default', className = '', ...props }) => {
11
+ export const Form: FC<FormProps> = ({
12
+ onSubmit,
13
+ children,
14
+ variant = 'default',
15
+ columns,
16
+ className = '',
17
+ style,
18
+ ...props
19
+ }) => {
10
20
  const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
11
21
  e.preventDefault();
12
- if (onSubmit) {
13
- onSubmit(e);
14
- }
22
+ onSubmit?.(e);
15
23
  };
16
24
 
17
- const getVariantClasses = () => {
25
+ const hasGrid = columns !== undefined && columns > 1;
26
+
27
+ const getVariantClasses = (): string => {
18
28
  switch (variant) {
19
29
  case 'modal':
20
30
  return 'flex-1 px-6 py-4 overflow-y-auto';
21
31
  case 'card':
22
- return 'p-6 space-y-6';
32
+ return `bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl shadow-sm p-6${hasGrid ? '' : ' space-y-6'}`;
23
33
  case 'inline':
24
- return 'flex flex-wrap gap-4 items-end';
34
+ return hasGrid ? '' : 'flex flex-wrap gap-4 items-end';
25
35
  case 'compact':
26
- return 'space-y-3';
36
+ return hasGrid ? '' : 'space-y-3';
27
37
  case 'default':
28
38
  default:
29
- return 'space-y-4';
39
+ return hasGrid ? '' : 'space-y-4';
30
40
  }
31
41
  };
32
42
 
33
- const combinedClassName = `${getVariantClasses()} ${className}`.trim();
43
+ const gridStyle: CSSProperties = hasGrid
44
+ ? { display: 'grid', gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`, gap: '1rem' }
45
+ : {};
46
+
47
+ const combinedClassName = [getVariantClasses(), className].filter(Boolean).join(' ');
48
+ const combinedStyle: CSSProperties = { ...gridStyle, ...style };
34
49
 
35
50
  return (
36
- <form onSubmit={handleSubmit} className={combinedClassName} {...props}>
51
+ <form
52
+ onSubmit={handleSubmit}
53
+ className={combinedClassName}
54
+ style={Object.keys(combinedStyle).length > 0 ? combinedStyle : undefined}
55
+ {...props}
56
+ >
37
57
  {children}
38
58
  </form>
39
59
  );
40
- };
60
+ };
@@ -1,19 +1,48 @@
1
1
  import { type InputHTMLAttributes, type FC, type ReactNode } from 'react';
2
+ import { CloseIcon } from '../icons/icons';
2
3
 
3
- interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
4
+ type InputVariant = 'default' | 'outline' | 'filled' | 'minimal';
5
+ type InputSize = 'sm' | 'md' | 'lg';
6
+
7
+ interface InputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'size' | 'prefix'> {
4
8
  label?: string | ReactNode;
5
9
  error?: string;
6
10
  helperText?: string;
7
11
  icon?: ReactNode;
8
12
  iconSide?: 'left' | 'right';
13
+ variant?: InputVariant;
14
+ size?: InputSize;
15
+ prefix?: ReactNode;
16
+ suffix?: ReactNode;
17
+ clearable?: boolean;
18
+ onClear?: () => void;
9
19
  }
10
20
 
21
+ const SIZE_CLASSES: Record<InputSize, string> = {
22
+ sm: 'px-2.5 py-1.5 text-xs',
23
+ md: 'px-3 py-2 text-sm',
24
+ lg: 'px-4 py-2.5 text-base',
25
+ };
26
+
27
+ const VARIANT_CLASSES: Record<InputVariant, string> = {
28
+ default: 'border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800',
29
+ outline: 'border-2 border-indigo-300 dark:border-indigo-600 bg-transparent',
30
+ filled: 'border border-gray-300 dark:border-gray-600 bg-gray-100 dark:bg-gray-700',
31
+ minimal: 'border-0 border-b border-gray-300 dark:border-gray-600 bg-transparent rounded-none focus:ring-0',
32
+ };
33
+
11
34
  export const Input: FC<InputProps> = ({
12
35
  label,
13
36
  error,
14
37
  helperText,
15
38
  icon,
16
39
  iconSide = 'left',
40
+ variant = 'default',
41
+ size = 'md',
42
+ prefix,
43
+ suffix,
44
+ clearable = false,
45
+ onClear,
17
46
  className = '',
18
47
  id,
19
48
  type,
@@ -21,32 +50,57 @@ export const Input: FC<InputProps> = ({
21
50
  }) => {
22
51
  const inputId = id || `input-${Math.random().toString(36).substring(2, 9)}`;
23
52
 
24
- // ── Default text input ────────────────────────────────────────────────────
25
- const baseClasses = 'appearance-none relative block w-full px-3 py-2 border placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-white bg-white dark:bg-gray-800 rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:focus:ring-indigo-400 focus:border-indigo-500 focus:z-10 sm:text-sm disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200';
26
- const errorClasses = error ? 'border-red-300 dark:border-red-600 focus:ring-red-500 dark:focus:ring-red-400 focus:border-red-500' : 'border-gray-300 dark:border-gray-600';
27
- const iconPaddingLeft = icon && iconSide === 'left' ? 'pl-9' : '';
28
- const iconPaddingRight = icon && iconSide === 'right' ? 'pr-9' : '';
29
- const classes = `${baseClasses} ${errorClasses} ${iconPaddingLeft} ${iconPaddingRight} ${className}`.trim();
53
+ const errorCls = error
54
+ ? 'border-red-300 dark:border-red-600 focus:ring-red-500 dark:focus:ring-red-400 focus:border-red-500'
55
+ : '';
56
+
57
+ const showClear = clearable && !props.disabled && props.value !== undefined && props.value !== '';
58
+ const hasRightSlot = (icon && iconSide === 'right') || showClear;
59
+
60
+ const baseCls = 'appearance-none relative block w-full placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-white rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:focus:ring-indigo-400 focus:border-indigo-500 focus:z-10 disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200';
61
+
62
+ const inputCls = [
63
+ baseCls,
64
+ SIZE_CLASSES[size],
65
+ VARIANT_CLASSES[variant],
66
+ errorCls,
67
+ icon && iconSide === 'left' ? 'pl-9' : '',
68
+ hasRightSlot ? 'pr-9' : '',
69
+ prefix ? 'rounded-l-none' : '',
70
+ suffix ? 'rounded-r-none' : '',
71
+ className,
72
+ ].filter(Boolean).join(' ');
30
73
 
31
74
  // ── Checkbox / Radio ──────────────────────────────────────────────────────
32
- const toggleShape = type === 'radio' ? 'rounded-full' : 'rounded';
33
- const toggleBaseClasses = `h-4 w-4 ${toggleShape} border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-indigo-600 dark:text-indigo-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:focus:ring-indigo-400 focus:ring-offset-2 focus:ring-offset-white dark:focus:ring-offset-gray-900 disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200 cursor-pointer`;
34
- const toggleErrorClasses = error ? 'border-red-300 dark:border-red-600 focus:ring-red-500 dark:focus:ring-red-400' : '';
35
- const toggleClasses = `${toggleBaseClasses} ${toggleErrorClasses}`.trim();
75
+ const toggleCls = [
76
+ `h-4 w-4 ${type === 'radio' ? 'rounded-full' : 'rounded'} border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800`,
77
+ 'text-indigo-600 dark:text-indigo-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:focus:ring-indigo-400',
78
+ 'focus:ring-offset-2 focus:ring-offset-white dark:focus:ring-offset-gray-900',
79
+ 'disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200 cursor-pointer',
80
+ error ? 'border-red-300 dark:border-red-600 focus:ring-red-500 dark:focus:ring-red-400' : '',
81
+ ].filter(Boolean).join(' ');
36
82
 
37
83
  // ── File input ────────────────────────────────────────────────────────────
38
- const fileBaseClasses = 'block w-full sm:text-sm text-gray-500 dark:text-gray-400 bg-white dark:bg-gray-800 border rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:focus:ring-indigo-400 focus:border-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200 file:mr-4 file:py-2 file:px-4 file:rounded-l-md file:border-0 file:text-sm file:font-medium file:bg-indigo-50 file:text-indigo-700 dark:file:bg-indigo-900/50 dark:file:text-indigo-300 hover:file:bg-indigo-100 dark:hover:file:bg-indigo-800/50 file:transition-colors file:duration-200 file:cursor-pointer';
39
- const fileErrorClasses = error ? 'border-red-300 dark:border-red-600 focus:ring-red-500 dark:focus:ring-red-400' : 'border-gray-300 dark:border-gray-600';
40
- const fileClasses = `${fileBaseClasses} ${fileErrorClasses} ${className}`.trim();
84
+ const fileCls = [
85
+ 'block w-full text-gray-500 dark:text-gray-400 bg-white dark:bg-gray-800 border rounded-md',
86
+ 'focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:focus:ring-indigo-400 focus:border-indigo-500',
87
+ 'disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200',
88
+ 'file:mr-4 file:py-2 file:px-4 file:rounded-l-md file:border-0 file:text-sm file:font-medium',
89
+ 'file:bg-indigo-50 file:text-indigo-700 dark:file:bg-indigo-900/50 dark:file:text-indigo-300',
90
+ 'hover:file:bg-indigo-100 dark:hover:file:bg-indigo-800/50 file:transition-colors file:duration-200 file:cursor-pointer',
91
+ SIZE_CLASSES[size],
92
+ error ? 'border-red-300 dark:border-red-600 focus:ring-red-500 dark:focus:ring-red-400' : 'border-gray-300 dark:border-gray-600',
93
+ className,
94
+ ].filter(Boolean).join(' ');
41
95
 
42
96
  const hasHidden = Boolean(className && /\bhidden\b/.test(className));
43
- const wrapperBase = 'space-y-1 w-full';
44
- const wrapperClasses = hasHidden ? `${wrapperBase} hidden` : wrapperBase;
97
+ const wrapperCls = `space-y-1 w-full${hasHidden ? ' hidden' : ''}`;
45
98
 
46
99
  const labelNode = label && (
47
100
  typeof label === 'string' ? (
48
101
  <label htmlFor={inputId} className="block text-sm font-medium text-gray-700 dark:text-gray-300">
49
102
  {label}
103
+ {props.required && <span className="ml-1 text-red-500" aria-hidden="true">*</span>}
50
104
  </label>
51
105
  ) : label
52
106
  );
@@ -61,9 +115,9 @@ export const Input: FC<InputProps> = ({
61
115
 
62
116
  if (type === 'checkbox' || type === 'radio') {
63
117
  return (
64
- <div className={wrapperClasses}>
118
+ <div className={wrapperCls}>
65
119
  <div className="flex items-center space-x-2">
66
- <input id={inputId} type={type} className={toggleClasses} {...props} />
120
+ <input id={inputId} type={type} className={toggleCls} {...props} />
67
121
  {labelNode}
68
122
  </div>
69
123
  {errorNode}
@@ -74,29 +128,54 @@ export const Input: FC<InputProps> = ({
74
128
 
75
129
  if (type === 'file') {
76
130
  return (
77
- <div className={wrapperClasses}>
131
+ <div className={wrapperCls}>
78
132
  {labelNode}
79
- <input id={inputId} type="file" className={fileClasses} {...props} />
133
+ <input id={inputId} type="file" className={fileCls} {...props} />
80
134
  {errorNode}
81
135
  {helperNode}
82
136
  </div>
83
137
  );
84
138
  }
85
139
 
140
+ const prefixBorderCls = error ? 'border-red-300 dark:border-red-600' : 'border-gray-300 dark:border-gray-600';
141
+
86
142
  return (
87
- <div className={wrapperClasses}>
143
+ <div className={wrapperCls}>
88
144
  {labelNode}
89
- <div className="relative">
90
- {icon && iconSide === 'left' && (
91
- <div className="pointer-events-none absolute inset-y-0 left-0 z-10 flex items-center pl-3 text-gray-400 dark:text-gray-500">
92
- {icon}
93
- </div>
145
+ <div className="flex">
146
+ {prefix && (
147
+ <span className={`inline-flex shrink-0 items-center px-3 border border-r-0 ${prefixBorderCls} bg-gray-50 dark:bg-gray-700 text-gray-500 dark:text-gray-400 rounded-l-md text-sm`}>
148
+ {prefix}
149
+ </span>
94
150
  )}
95
- <input id={inputId} className={classes} type={type} {...props} />
96
- {icon && iconSide === 'right' && (
97
- <div className="pointer-events-none absolute inset-y-0 right-0 z-10 flex items-center pr-3 text-gray-400 dark:text-gray-500">
98
- {icon}
99
- </div>
151
+ <div className="relative flex-1">
152
+ {icon && iconSide === 'left' && (
153
+ <div className="pointer-events-none absolute inset-y-0 left-0 z-10 flex items-center pl-3 text-gray-400 dark:text-gray-500">
154
+ {icon}
155
+ </div>
156
+ )}
157
+ <input id={inputId} className={inputCls} type={type} {...props} />
158
+ {icon && iconSide === 'right' && !showClear && (
159
+ <div className="pointer-events-none absolute inset-y-0 right-0 z-10 flex items-center pr-3 text-gray-400 dark:text-gray-500">
160
+ {icon}
161
+ </div>
162
+ )}
163
+ {showClear && (
164
+ <button
165
+ type="button"
166
+ onClick={onClear}
167
+ tabIndex={-1}
168
+ aria-label="Limpiar"
169
+ className="absolute inset-y-0 right-0 z-10 flex items-center pr-3 text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300"
170
+ >
171
+ <CloseIcon className="w-4 h-4" />
172
+ </button>
173
+ )}
174
+ </div>
175
+ {suffix && (
176
+ <span className={`inline-flex shrink-0 items-center px-3 border border-l-0 ${prefixBorderCls} bg-gray-50 dark:bg-gray-700 text-gray-500 dark:text-gray-400 rounded-r-md text-sm`}>
177
+ {suffix}
178
+ </span>
100
179
  )}
101
180
  </div>
102
181
  {errorNode}
@@ -6,6 +6,10 @@ interface LoadingProps {
6
6
  color?: 'primary' | 'white' | 'gray' | 'success' | 'danger' | 'warning';
7
7
  label?: string;
8
8
  className?: string;
9
+ /** Renders over the nearest positioned ancestor (parent needs position: relative) */
10
+ overlay?: boolean;
11
+ /** Renders as a fixed full-page overlay */
12
+ fullPage?: boolean;
9
13
  }
10
14
 
11
15
  export const Loading: FC<LoadingProps> = ({
@@ -14,20 +18,22 @@ export const Loading: FC<LoadingProps> = ({
14
18
  color = 'primary',
15
19
  label,
16
20
  className = '',
21
+ overlay = false,
22
+ fullPage = false,
17
23
  }) => {
18
24
  const sizeClasses = {
19
- small: 'h-4 w-4',
25
+ small: 'h-4 w-4',
20
26
  medium: 'h-8 w-8',
21
- large: 'h-12 w-12',
22
- xl: 'h-16 w-16',
27
+ large: 'h-12 w-12',
28
+ xl: 'h-16 w-16',
23
29
  };
24
30
 
25
31
  const colorClasses = {
26
32
  primary: 'text-indigo-600 dark:text-indigo-400',
27
- white: 'text-white',
28
- gray: 'text-gray-500 dark:text-gray-400',
33
+ white: 'text-white',
34
+ gray: 'text-gray-500 dark:text-gray-400',
29
35
  success: 'text-green-600 dark:text-green-400',
30
- danger: 'text-red-600 dark:text-red-400',
36
+ danger: 'text-red-600 dark:text-red-400',
31
37
  warning: 'text-yellow-600 dark:text-yellow-400',
32
38
  };
33
39
 
@@ -35,69 +41,88 @@ export const Loading: FC<LoadingProps> = ({
35
41
  const commonClass = `${sizeClasses[size]} ${colorClasses[color]}`;
36
42
 
37
43
  switch (variant) {
38
- case 'dots':
44
+ case 'dots': {
39
45
  const dotSize = size === 'small' ? 'h-1 w-1' : size === 'medium' ? 'h-2 w-2' : size === 'large' ? 'h-3 w-3' : 'h-4 w-4';
40
46
  return (
41
47
  <div className={`flex space-x-1 ${colorClasses[color]}`}>
42
- <div className={`rounded-full bg-current animate-bounce ${dotSize}`} style={{ animationDelay: '0s' }}></div>
43
- <div className={`rounded-full bg-current animate-bounce ${dotSize}`} style={{ animationDelay: '0.15s' }}></div>
44
- <div className={`rounded-full bg-current animate-bounce ${dotSize}`} style={{ animationDelay: '0.3s' }}></div>
48
+ <div className={`rounded-full bg-current animate-bounce ${dotSize}`} style={{ animationDelay: '0s' }} />
49
+ <div className={`rounded-full bg-current animate-bounce ${dotSize}`} style={{ animationDelay: '0.15s' }} />
50
+ <div className={`rounded-full bg-current animate-bounce ${dotSize}`} style={{ animationDelay: '0.3s' }} />
45
51
  </div>
46
52
  );
47
-
53
+ }
48
54
  case 'bars':
49
55
  return (
50
56
  <div className={`flex items-end space-x-1 ${sizeClasses[size]} ${colorClasses[color]}`}>
51
- <div className="w-1/4 bg-current animate-[pulse_1s_ease-in-out_infinite]" style={{ height: '60%', animationDelay: '0s' }}></div>
52
- <div className="w-1/4 bg-current animate-[pulse_1s_ease-in-out_infinite]" style={{ height: '100%', animationDelay: '0.2s' }}></div>
53
- <div className="w-1/4 bg-current animate-[pulse_1s_ease-in-out_infinite]" style={{ height: '60%', animationDelay: '0.4s' }}></div>
57
+ <div className="w-1/4 bg-current animate-[pulse_1s_ease-in-out_infinite]" style={{ height: '60%', animationDelay: '0s' }} />
58
+ <div className="w-1/4 bg-current animate-[pulse_1s_ease-in-out_infinite]" style={{ height: '100%', animationDelay: '0.2s' }} />
59
+ <div className="w-1/4 bg-current animate-[pulse_1s_ease-in-out_infinite]" style={{ height: '60%', animationDelay: '0.4s' }} />
54
60
  </div>
55
61
  );
56
-
57
62
  case 'pulse':
58
63
  return (
59
64
  <span className={`relative flex ${commonClass}`}>
60
- <span className="animate-ping absolute inline-flex h-full w-full rounded-full opacity-75 bg-current"></span>
61
- <span className="relative inline-flex rounded-full h-full w-full bg-current"></span>
65
+ <span className="animate-ping absolute inline-flex h-full w-full rounded-full opacity-75 bg-current" />
66
+ <span className="relative inline-flex rounded-full h-full w-full bg-current" />
62
67
  </span>
63
68
  );
64
-
65
69
  case 'cube':
66
70
  return (
67
- <div className={`${commonClass} grid grid-cols-2 gap-1`}>
68
- <div className="bg-current animate-[pulse_2s_ease-in-out_infinite]" style={{ animationDelay: '0s' }}></div>
69
- <div className="bg-current animate-[pulse_2s_ease-in-out_infinite]" style={{ animationDelay: '0.5s' }}></div>
70
- <div className="bg-current animate-[pulse_2s_ease-in-out_infinite]" style={{ animationDelay: '1.5s' }}></div>
71
- <div className="bg-current animate-[pulse_2s_ease-in-out_infinite]" style={{ animationDelay: '1s' }}></div>
72
- </div>
71
+ <div className={`${commonClass} grid grid-cols-2 gap-1`}>
72
+ <div className="bg-current animate-[pulse_2s_ease-in-out_infinite]" style={{ animationDelay: '0s' }} />
73
+ <div className="bg-current animate-[pulse_2s_ease-in-out_infinite]" style={{ animationDelay: '0.5s' }} />
74
+ <div className="bg-current animate-[pulse_2s_ease-in-out_infinite]" style={{ animationDelay: '1.5s' }} />
75
+ <div className="bg-current animate-[pulse_2s_ease-in-out_infinite]" style={{ animationDelay: '1s' }} />
76
+ </div>
73
77
  );
74
-
75
78
  case 'ring':
76
79
  return (
77
80
  <svg className={`${commonClass} animate-spin`} xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
78
- <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
79
- <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>
81
+ <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
82
+ <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" />
80
83
  </svg>
81
84
  );
82
-
83
85
  case 'spinner':
84
86
  default:
85
87
  return (
86
88
  <svg className={`${commonClass} animate-spin`} xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
87
- <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
88
- <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"></path>
89
+ <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
90
+ <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
89
91
  </svg>
90
92
  );
91
93
  }
92
94
  };
93
95
 
96
+ const inner = (
97
+ <div className={`flex flex-col items-center justify-center ${className}`} role="status">
98
+ {renderIcon()}
99
+ {label && (
100
+ <span className={`mt-3 text-sm font-medium ${colorClasses[color]}`}>{label}</span>
101
+ )}
102
+ </div>
103
+ );
104
+
105
+ if (fullPage) {
106
+ return (
107
+ <div className="fixed inset-0 z-50 flex items-center justify-center bg-gray-900/50 backdrop-blur-sm">
108
+ {inner}
109
+ </div>
110
+ );
111
+ }
112
+
113
+ if (overlay) {
114
+ return (
115
+ <div className="absolute inset-0 z-10 flex items-center justify-center bg-gray-900/40 backdrop-blur-sm rounded-[inherit]">
116
+ {inner}
117
+ </div>
118
+ );
119
+ }
120
+
94
121
  return (
95
122
  <div className={`flex flex-col items-center justify-center w-full h-full min-h-[inherit] ${className}`} role="status">
96
123
  {renderIcon()}
97
124
  {label && (
98
- <span className={`mt-3 text-sm font-medium ${colorClasses[color]}`}>
99
- {label}
100
- </span>
125
+ <span className={`mt-3 text-sm font-medium ${colorClasses[color]}`}>{label}</span>
101
126
  )}
102
127
  </div>
103
128
  );