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
@@ -1,62 +1,453 @@
1
- import { type ReactNode } from 'react';
1
+ import { type ReactNode, type CSSProperties } from 'react';
2
+ import { SortAscIcon, SortDescIcon, SortBothIcon } from '../icons/icons';
3
+
4
+ type TableVariant =
5
+ | 'default'
6
+ | 'striped'
7
+ | 'bordered'
8
+ | 'minimal'
9
+ | 'ghost'
10
+ | 'card'
11
+ | 'accent'
12
+ | 'dark'
13
+ | 'custom';
14
+
15
+ type TableSize = 'sm' | 'md' | 'lg';
16
+
17
+ interface SortState {
18
+ key: string;
19
+ direction: 'asc' | 'desc';
20
+ }
21
+
22
+ interface ColumnDef {
23
+ /** Contenido del encabezado */
24
+ header: ReactNode;
25
+ /** Clase CSS adicional para toda la columna (th + td) */
26
+ className?: string;
27
+ /** Alinear el contenido de esta columna */
28
+ align?: 'left' | 'center' | 'right';
29
+ /** Ancho fijo (px, %, rem…) */
30
+ width?: string | number;
31
+ /** Ancho mínimo */
32
+ minWidth?: string | number;
33
+ /** Fija esta columna a la izquierda durante scroll horizontal */
34
+ sticky?: boolean;
35
+ /** Estilos inline exclusivos para <th> */
36
+ thStyle?: CSSProperties;
37
+ /** Estilos inline exclusivos para <td> */
38
+ tdStyle?: CSSProperties;
39
+ /** Muestra indicador de ordenación. Requiere `key` */
40
+ sortable?: boolean;
41
+ /** Clave usada en sortState y onSort */
42
+ key?: string;
43
+ }
2
44
 
3
45
  interface TableProps {
4
- headers: ReactNode[];
46
+ /**
47
+ * Definición de columnas. Acepta strings simples o ColumnDef para
48
+ * configuración avanzada (ancho, sticky, sort, etc.).
49
+ */
50
+ columns: (ColumnDef | ReactNode)[];
51
+
52
+ /** Filas del cuerpo. Cada fila es un arreglo de celdas. */
5
53
  rows: ReactNode[][];
6
- variant?: 'default' | 'custom';
54
+
55
+ /** Estilo visual. Default: 'default' */
56
+ variant?: TableVariant;
57
+
58
+ /** Tamaño de padding. Default: 'md' */
59
+ size?: TableSize;
60
+
61
+ /** Clase CSS adicional para el wrapper <div> */
7
62
  className?: string;
63
+
64
+ /** Clase CSS adicional para el <table> */
65
+ tableClassName?: string;
66
+
67
+ /** Clase CSS adicional para cada <th> */
8
68
  thClassName?: string;
69
+
70
+ /** Clase CSS adicional para cada <td> */
9
71
  tdClassName?: string;
72
+
73
+ /** Clase CSS adicional por fila del body */
74
+ trClassName?: string | ((rowIndex: number) => string);
75
+
76
+ /** Nodo a mostrar cuando rows está vacío */
77
+ emptyState?: ReactNode;
78
+
79
+ /** Callback al hacer click en una fila */
80
+ onRowClick?: (rowIndex: number) => void;
81
+
82
+ /** Oculta el thead */
83
+ hideHeader?: boolean;
84
+
85
+ /** Estilos inline para el <table> */
86
+ style?: CSSProperties;
87
+
88
+ /** Fija el thead al hacer scroll vertical */
89
+ stickyHeader?: boolean;
90
+
91
+ /** Caption accesible de la tabla */
92
+ caption?: ReactNode;
93
+
94
+ /** Filas del <tfoot> */
95
+ footerRows?: ReactNode[][];
96
+
97
+ /** Muestra esqueleto animado en lugar de rows */
98
+ loading?: boolean;
99
+
100
+ /** Número de filas esqueleto cuando loading=true. Default: 4 */
101
+ loadingRows?: number;
102
+
103
+ /** Estilo inline por fila del body */
104
+ getRowStyle?: (rowIndex: number) => CSSProperties;
105
+
106
+ /** Agrega rounded-lg al wrapper */
107
+ rounded?: boolean;
108
+
109
+ /** Agrega sombra al wrapper */
110
+ shadow?: boolean;
111
+
112
+ /** Desactiva el efecto hover en filas. Default: true */
113
+ hoverable?: boolean;
114
+
115
+ /** Estado actual de ordenación */
116
+ sortState?: SortState | null;
117
+
118
+ /** Callback al hacer click en un th sortable */
119
+ onSort?: (key: string) => void;
10
120
  }
11
121
 
122
+ // ─── Lookup tables ────────────────────────────────────────────────────────────
123
+
124
+ const ALIGN_CLASS: Record<string, string> = {
125
+ left: 'text-left',
126
+ center: 'text-center',
127
+ right: 'text-right',
128
+ };
129
+
130
+ const SIZE_TH: Record<TableSize, string> = {
131
+ sm: 'px-2 py-1.5 text-xs',
132
+ md: 'px-3 py-2.5 text-xs',
133
+ lg: 'px-4 py-3.5 text-sm',
134
+ };
135
+
136
+ const SIZE_TD: Record<TableSize, string> = {
137
+ sm: 'px-2 py-1.5 text-xs',
138
+ md: 'px-3 py-2.5 text-sm',
139
+ lg: 'px-4 py-3.5 text-sm',
140
+ };
141
+
142
+ const VARIANT_TABLE: Record<TableVariant, string> = {
143
+ default: 'w-full min-w-full table-auto',
144
+ striped: 'w-full min-w-full table-auto',
145
+ bordered: 'w-full min-w-full table-auto border border-gray-300 dark:border-gray-600',
146
+ minimal: 'w-full min-w-full table-auto',
147
+ ghost: 'w-full min-w-full table-auto',
148
+ card: 'w-full min-w-full table-auto',
149
+ accent: 'w-full min-w-full table-auto',
150
+ dark: 'w-full min-w-full table-auto',
151
+ custom: 'w-full min-w-full table-auto',
152
+ };
153
+
154
+ const VARIANT_THEAD: Record<TableVariant, string> = {
155
+ default: 'bg-gray-100 dark:bg-gray-700',
156
+ striped: 'bg-gray-100 dark:bg-gray-700',
157
+ bordered: 'bg-gray-100 dark:bg-gray-700',
158
+ minimal: '',
159
+ ghost: '',
160
+ card: 'bg-gray-50 dark:bg-gray-800/80',
161
+ accent: 'bg-blue-600 dark:bg-blue-700',
162
+ dark: 'bg-gray-800 dark:bg-gray-900',
163
+ custom: '',
164
+ };
165
+
166
+ const VARIANT_TH: Record<TableVariant, string> = {
167
+ default: 'font-semibold uppercase tracking-wider text-gray-600 dark:text-gray-300',
168
+ striped: 'font-semibold uppercase tracking-wider text-gray-600 dark:text-gray-300',
169
+ bordered: 'font-semibold uppercase tracking-wider text-gray-600 dark:text-gray-300 border border-gray-300 dark:border-gray-600',
170
+ minimal: 'font-semibold text-gray-500 dark:text-gray-400 border-b border-gray-200 dark:border-gray-700',
171
+ ghost: 'font-semibold text-gray-600 dark:text-gray-400 border-b-2 border-gray-300 dark:border-gray-600',
172
+ card: 'font-semibold text-gray-600 dark:text-gray-300 border-b border-gray-200 dark:border-gray-700',
173
+ accent: 'font-semibold uppercase tracking-wider text-white',
174
+ dark: 'font-semibold uppercase tracking-wider text-gray-100',
175
+ custom: '',
176
+ };
177
+
178
+ // Base row bg per variant (overridden by stripe for 'striped')
179
+ const VARIANT_TR_BASE: Record<TableVariant, string> = {
180
+ default: 'bg-white dark:bg-gray-800',
181
+ striped: '',
182
+ bordered: 'bg-white dark:bg-gray-800',
183
+ minimal: '',
184
+ ghost: '',
185
+ card: 'bg-white dark:bg-gray-900',
186
+ accent: 'bg-white dark:bg-gray-800',
187
+ dark: 'bg-white dark:bg-gray-800',
188
+ custom: '',
189
+ };
190
+
191
+ const VARIANT_TR_HOVER: Record<TableVariant, string> = {
192
+ default: 'hover:bg-gray-50 dark:hover:bg-gray-700/60',
193
+ striped: 'hover:bg-blue-50 dark:hover:bg-blue-900/20',
194
+ bordered: 'hover:bg-gray-50 dark:hover:bg-gray-700/60',
195
+ minimal: 'hover:bg-gray-50 dark:hover:bg-gray-800/60',
196
+ ghost: 'hover:bg-gray-50/70 dark:hover:bg-white/5',
197
+ card: 'hover:bg-gray-50 dark:hover:bg-gray-800/50',
198
+ accent: 'hover:bg-blue-50 dark:hover:bg-blue-900/20',
199
+ dark: 'hover:bg-gray-50 dark:hover:bg-gray-700/60',
200
+ custom: '',
201
+ };
202
+
203
+ const VARIANT_TR_STRIPE: Record<TableVariant, (i: number) => string> = {
204
+ default: () => '',
205
+ striped: (i) => i % 2 === 0
206
+ ? 'bg-white dark:bg-gray-800'
207
+ : 'bg-gray-50 dark:bg-gray-700/40',
208
+ bordered: () => '',
209
+ minimal: () => '',
210
+ ghost: () => '',
211
+ card: () => '',
212
+ accent: () => '',
213
+ dark: () => '',
214
+ custom: () => '',
215
+ };
216
+
217
+ const VARIANT_TD: Record<TableVariant, string> = {
218
+ default: 'text-gray-700 dark:text-gray-300',
219
+ striped: 'text-gray-700 dark:text-gray-300',
220
+ bordered: 'text-gray-700 dark:text-gray-300 border border-gray-200 dark:border-gray-700',
221
+ minimal: 'text-gray-700 dark:text-gray-300 border-b border-gray-100 dark:border-gray-800',
222
+ ghost: 'text-gray-700 dark:text-gray-300',
223
+ card: 'text-gray-700 dark:text-gray-300',
224
+ accent: 'text-gray-700 dark:text-gray-300',
225
+ dark: 'text-gray-700 dark:text-gray-300',
226
+ custom: '',
227
+ };
228
+
229
+ const VARIANT_TBODY_DIVIDER: Record<TableVariant, string> = {
230
+ default: 'divide-y divide-gray-200 dark:divide-gray-700',
231
+ striped: '',
232
+ bordered: '',
233
+ minimal: '',
234
+ ghost: 'divide-y divide-gray-200 dark:divide-gray-700',
235
+ card: 'divide-y divide-gray-100 dark:divide-gray-800',
236
+ accent: 'divide-y divide-gray-200 dark:divide-gray-700',
237
+ dark: 'divide-y divide-gray-200 dark:divide-gray-700',
238
+ custom: '',
239
+ };
240
+
241
+ // ─── Sub-components ───────────────────────────────────────────────────────────
242
+
243
+ function SortIcon({ direction }: { direction?: 'asc' | 'desc' | null }) {
244
+ if (direction === 'asc') return <SortAscIcon className="inline-block ml-1 w-3 h-3 shrink-0" />;
245
+ if (direction === 'desc') return <SortDescIcon className="inline-block ml-1 w-3 h-3 shrink-0" />;
246
+ return <SortBothIcon className="inline-block ml-1 w-3 h-3 shrink-0 opacity-40" />;
247
+ }
248
+
249
+ function SkeletonRow({ colCount, size }: { colCount: number; size: TableSize }) {
250
+ return (
251
+ <tr>
252
+ {Array.from({ length: colCount }).map((_, i) => (
253
+ <td key={i} className={SIZE_TD[size]}>
254
+ <div className="h-4 rounded bg-gray-200 dark:bg-gray-700 animate-pulse" />
255
+ </td>
256
+ ))}
257
+ </tr>
258
+ );
259
+ }
260
+
261
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
262
+
263
+ function isColumnDef(col: ColumnDef | ReactNode): col is ColumnDef {
264
+ return typeof col === 'object' && col !== null && 'header' in (col as object);
265
+ }
266
+
267
+ function resolveColumn(col: ColumnDef | ReactNode): ColumnDef {
268
+ if (isColumnDef(col)) return col;
269
+ return { header: col };
270
+ }
271
+
272
+ function colSizeStyle(col: ColumnDef): CSSProperties {
273
+ const s: CSSProperties = {};
274
+ if (col.width !== undefined) s.width = col.width;
275
+ if (col.minWidth !== undefined) s.minWidth = col.minWidth;
276
+ return s;
277
+ }
278
+
279
+ // ─── Component ───────────────────────────────────────────────────────────────
280
+
12
281
  export function Table({
13
- headers,
282
+ columns,
14
283
  rows,
15
284
  variant = 'default',
285
+ size = 'md',
16
286
  className = '',
287
+ tableClassName = '',
17
288
  thClassName = '',
18
- tdClassName = ''
289
+ tdClassName = '',
290
+ trClassName,
291
+ emptyState,
292
+ onRowClick,
293
+ hideHeader = false,
294
+ style,
295
+ stickyHeader = false,
296
+ caption,
297
+ footerRows,
298
+ loading = false,
299
+ loadingRows = 4,
300
+ getRowStyle,
301
+ rounded = false,
302
+ shadow = false,
303
+ hoverable = true,
304
+ sortState,
305
+ onSort,
19
306
  }: TableProps) {
20
- const baseTableClass = variant === 'default'
21
- ? 'w-full table-auto border-collapse border border-gray-300 dark:border-gray-600 min-w-full'
22
- : '';
307
+ const cols = columns.map(resolveColumn);
23
308
 
24
- const baseThClass = variant === 'default'
25
- ? 'border border-gray-300 dark:border-gray-600 px-4 py-2 text-left text-gray-900 dark:text-white'
26
- : '';
309
+ const resolvedTrClass = (i: number): string => {
310
+ const stripeCls = VARIANT_TR_STRIPE[variant](i);
311
+ const baseCls = stripeCls || VARIANT_TR_BASE[variant];
312
+ const hoverCls = hoverable ? `${VARIANT_TR_HOVER[variant]} transition-colors` : '';
313
+ const clickCls = onRowClick ? 'cursor-pointer' : '';
314
+ const customCls = typeof trClassName === 'function' ? trClassName(i) : (trClassName ?? '');
315
+ return [baseCls, hoverCls, clickCls, customCls].filter(Boolean).join(' ');
316
+ };
27
317
 
28
- const baseTdClass = variant === 'default'
29
- ? 'border border-gray-300 dark:border-gray-600 px-4 py-2 text-gray-900 dark:text-white'
30
- : '';
318
+ const wrapperCls = [
319
+ 'overflow-x-auto w-full',
320
+ rounded ? 'rounded-lg overflow-hidden' : '',
321
+ shadow ? 'shadow-md' : '',
322
+ className,
323
+ ].filter(Boolean).join(' ');
31
324
 
32
- const tableClass = `${baseTableClass} ${className}`.trim();
33
- const thClass = `${baseThClass} ${thClassName}`.trim();
34
- const tdClass = `${baseTdClass} ${tdClassName}`.trim();
325
+ const theadCls = [
326
+ VARIANT_THEAD[variant],
327
+ stickyHeader ? 'sticky top-0 z-20' : '',
328
+ ].filter(Boolean).join(' ');
329
+
330
+ const stickyColCls = 'sticky left-0 z-10 bg-inherit';
35
331
 
36
332
  return (
37
- <div className="overflow-x-auto w-full">
38
- <table className={tableClass}>
39
- <thead>
40
- <tr className={variant === 'default' ? 'bg-gray-100 dark:bg-gray-700' : ''}>
41
- {headers.map((header, index) => (
42
- <th key={index} className={thClass}>
43
- {header}
44
- </th>
45
- ))}
46
- </tr>
47
- </thead>
48
- <tbody>
49
- {rows.map((row, rowIndex) => (
50
- <tr key={rowIndex} className={variant === 'default' ? 'hover:bg-gray-50 dark:hover:bg-gray-600' : ''}>
51
- {row.map((cell, cellIndex) => (
52
- <td key={cellIndex} className={tdClass}>
53
- {cell}
54
- </td>
55
- ))}
333
+ <div className={wrapperCls}>
334
+ <table
335
+ className={`${VARIANT_TABLE[variant]} ${tableClassName}`.trim()}
336
+ style={style}
337
+ >
338
+ {caption && (
339
+ <caption className="mb-2 text-left text-sm text-gray-500 dark:text-gray-400">
340
+ {caption}
341
+ </caption>
342
+ )}
343
+
344
+ {!hideHeader && (
345
+ <thead className={theadCls}>
346
+ <tr>
347
+ {cols.map((col, i) => {
348
+ const isSortable = col.sortable && col.key;
349
+ const activeSort = sortState?.key === col.key ? sortState!.direction : null;
350
+ return (
351
+ <th
352
+ key={i}
353
+ scope="col"
354
+ className={[
355
+ SIZE_TH[size],
356
+ VARIANT_TH[variant],
357
+ ALIGN_CLASS[col.align ?? 'left'],
358
+ col.className ?? '',
359
+ thClassName,
360
+ col.sticky ? stickyColCls : '',
361
+ isSortable ? 'cursor-pointer select-none' : '',
362
+ ].filter(Boolean).join(' ')}
363
+ style={{ ...colSizeStyle(col), ...(col.thStyle ?? {}) }}
364
+ onClick={isSortable ? () => onSort?.(col.key!) : undefined}
365
+ >
366
+ {col.header}
367
+ {isSortable && <SortIcon direction={activeSort} />}
368
+ </th>
369
+ );
370
+ })}
371
+ </tr>
372
+ </thead>
373
+ )}
374
+
375
+ <tbody className={VARIANT_TBODY_DIVIDER[variant]}>
376
+ {loading ? (
377
+ Array.from({ length: loadingRows }).map((_, i) => (
378
+ <SkeletonRow key={i} colCount={cols.length} size={size} />
379
+ ))
380
+ ) : rows.length === 0 ? (
381
+ <tr>
382
+ <td
383
+ colSpan={cols.length}
384
+ className={`${SIZE_TD[size]} py-8 text-center text-gray-400 dark:text-gray-500`}
385
+ >
386
+ {emptyState ?? 'Sin datos'}
387
+ </td>
56
388
  </tr>
57
- ))}
389
+ ) : (
390
+ rows.map((row, rowIndex) => (
391
+ <tr
392
+ key={rowIndex}
393
+ className={resolvedTrClass(rowIndex)}
394
+ style={getRowStyle?.(rowIndex)}
395
+ onClick={onRowClick ? () => onRowClick(rowIndex) : undefined}
396
+ >
397
+ {row.map((cell, cellIndex) => {
398
+ const col = cols[cellIndex];
399
+ return (
400
+ <td
401
+ key={cellIndex}
402
+ className={[
403
+ SIZE_TD[size],
404
+ VARIANT_TD[variant],
405
+ ALIGN_CLASS[col?.align ?? 'left'],
406
+ col?.className ?? '',
407
+ tdClassName,
408
+ col?.sticky ? stickyColCls : '',
409
+ ].filter(Boolean).join(' ')}
410
+ style={{
411
+ ...(col ? colSizeStyle(col) : {}),
412
+ ...(col?.tdStyle ?? {}),
413
+ }}
414
+ >
415
+ {cell}
416
+ </td>
417
+ );
418
+ })}
419
+ </tr>
420
+ ))
421
+ )}
58
422
  </tbody>
423
+
424
+ {footerRows && footerRows.length > 0 && (
425
+ <tfoot className="border-t border-gray-200 bg-gray-50 dark:border-gray-700 dark:bg-gray-800/50">
426
+ {footerRows.map((row, rowIndex) => (
427
+ <tr key={rowIndex}>
428
+ {row.map((cell, cellIndex) => {
429
+ const col = cols[cellIndex];
430
+ return (
431
+ <td
432
+ key={cellIndex}
433
+ className={[
434
+ SIZE_TD[size],
435
+ 'font-medium text-gray-700 dark:text-gray-300',
436
+ ALIGN_CLASS[col?.align ?? 'left'],
437
+ col?.className ?? '',
438
+ tdClassName,
439
+ ].filter(Boolean).join(' ')}
440
+ style={col?.tdStyle}
441
+ >
442
+ {cell}
443
+ </td>
444
+ );
445
+ })}
446
+ </tr>
447
+ ))}
448
+ </tfoot>
449
+ )}
59
450
  </table>
60
451
  </div>
61
452
  );
62
- }
453
+ }
@@ -1,71 +1,121 @@
1
- import { type TextareaHTMLAttributes, type FC, type ReactNode } from 'react';
1
+ import { type TextareaHTMLAttributes, type FC, type ReactNode, useRef, useEffect } from 'react';
2
+
3
+ type TextAreaVariant = 'default' | 'outline' | 'filled' | 'minimal';
4
+ type TextAreaSize = 'small' | 'medium' | 'large';
5
+ type ResizeOption = 'vertical' | 'horizontal' | 'both' | 'none';
2
6
 
3
7
  interface TextAreaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
4
8
  label?: string | ReactNode;
5
9
  error?: string;
6
10
  helperText?: string;
7
- variant?: 'default' | 'outline' | 'filled' | 'minimal';
8
- size?: 'small' | 'medium' | 'large';
11
+ variant?: TextAreaVariant;
12
+ size?: TextAreaSize;
13
+ autoResize?: boolean;
14
+ showCount?: boolean;
15
+ resize?: ResizeOption;
9
16
  }
10
17
 
18
+ const SIZE_CLASSES: Record<TextAreaSize, string> = {
19
+ small: 'px-2 py-1 text-xs',
20
+ medium: 'px-3 py-2 text-sm',
21
+ large: 'px-4 py-3 text-base',
22
+ };
23
+
24
+ const VARIANT_CLASSES: Record<TextAreaVariant, string> = {
25
+ default: 'border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800',
26
+ outline: 'border-2 border-indigo-300 dark:border-indigo-600 bg-transparent',
27
+ filled: 'border border-gray-300 dark:border-gray-600 bg-gray-100 dark:bg-gray-700',
28
+ minimal: 'border-0 border-b border-gray-300 dark:border-gray-600 bg-transparent rounded-none focus:ring-0',
29
+ };
30
+
31
+ const RESIZE_CLASSES: Record<ResizeOption, string> = {
32
+ vertical: 'resize-y',
33
+ horizontal: 'resize-x',
34
+ both: 'resize',
35
+ none: 'resize-none',
36
+ };
37
+
11
38
  export const TextArea: FC<TextAreaProps> = ({
12
39
  label,
13
40
  error,
14
41
  helperText,
15
42
  variant = 'default',
16
43
  size = 'medium',
44
+ autoResize = false,
45
+ showCount = false,
46
+ resize = 'vertical',
17
47
  className = '',
18
48
  id,
49
+ onInput: propsOnInput,
19
50
  ...props
20
51
  }) => {
21
52
  const textAreaId = id || `textarea-${Math.random().toString(36).substring(2, 9)}`;
53
+ const textareaRef = useRef<HTMLTextAreaElement>(null);
22
54
 
23
- const sizeClasses = {
24
- small: 'px-2 py-1 text-xs',
25
- medium: 'px-3 py-2 text-sm',
26
- large: 'px-4 py-3 text-base'
55
+ const adjustHeight = () => {
56
+ const el = textareaRef.current;
57
+ if (!el) return;
58
+ el.style.height = 'auto';
59
+ el.style.height = `${el.scrollHeight}px`;
27
60
  };
28
61
 
29
- const variantClasses = {
30
- default: 'border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800',
31
- outline: 'border-2 border-indigo-300 dark:border-indigo-600 bg-transparent',
32
- filled: 'border-gray-300 dark:border-gray-600 bg-gray-100 dark:bg-gray-700',
33
- minimal: 'border-0 bg-transparent focus:ring-0 focus:border-0'
62
+ useEffect(() => {
63
+ if (autoResize) adjustHeight();
64
+ }, [autoResize, props.value]);
65
+
66
+ const handleInput = (e: React.FormEvent<HTMLTextAreaElement>) => {
67
+ if (autoResize) adjustHeight();
68
+ propsOnInput?.(e);
34
69
  };
35
70
 
36
- const baseClasses = 'appearance-none relative block w-full placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-white rounded-md border 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 resize-y';
71
+ const baseCls = 'appearance-none relative block w-full placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-white rounded-md border 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';
72
+ const errorCls = error ? 'border-red-300 dark:border-red-600 focus:ring-red-500 dark:focus:ring-red-400 focus:border-red-500' : '';
73
+ const resizeCls = autoResize ? 'resize-none overflow-hidden' : RESIZE_CLASSES[resize];
74
+
75
+ const classes = [baseCls, SIZE_CLASSES[size], VARIANT_CLASSES[variant], errorCls, resizeCls, className]
76
+ .filter(Boolean).join(' ');
37
77
 
38
- const errorClasses = error ? 'border-red-300 dark:border-red-600 focus:ring-red-500 dark:focus:ring-red-400 focus:border-red-500' : '';
78
+ const maxLength = typeof props.maxLength === 'number' ? props.maxLength : undefined;
79
+ const currentLength =
80
+ typeof props.value === 'string' ? props.value.length :
81
+ typeof props.value === 'number' ? String(props.value).length : 0;
39
82
 
40
- const classes = `${baseClasses} ${sizeClasses[size]} ${variantClasses[variant]} ${errorClasses} ${className}`;
83
+ const overLimit = maxLength !== undefined && currentLength > maxLength;
41
84
 
42
85
  return (
43
86
  <div className="space-y-1 w-full">
44
- {label && typeof label === 'string' ? (
45
- <label
46
- htmlFor={textAreaId}
47
- className="block text-sm font-medium text-gray-700 dark:text-gray-300"
48
- >
49
- {label}
50
- </label>
51
- ) : (
52
- label
87
+ {(label || showCount) && (
88
+ <div className="flex items-baseline gap-2">
89
+ <div className="flex-1">
90
+ {label && (
91
+ typeof label === 'string' ? (
92
+ <label htmlFor={textAreaId} className="block text-sm font-medium text-gray-700 dark:text-gray-300">
93
+ {label}
94
+ {props.required && <span className="ml-1 text-red-500" aria-hidden="true">*</span>}
95
+ </label>
96
+ ) : label
97
+ )}
98
+ </div>
99
+ {showCount && (
100
+ <span className={`text-xs shrink-0 tabular-nums ${overLimit ? 'text-red-500' : 'text-gray-400 dark:text-gray-500'}`}>
101
+ {maxLength ? `${currentLength} / ${maxLength}` : currentLength}
102
+ </span>
103
+ )}
104
+ </div>
53
105
  )}
54
106
  <textarea
107
+ ref={textareaRef}
55
108
  id={textAreaId}
56
109
  className={classes}
110
+ onInput={handleInput}
57
111
  {...props}
58
112
  />
59
113
  {error && (
60
- <p className="text-sm text-red-600 dark:text-red-400" role="alert">
61
- {error}
62
- </p>
114
+ <p className="text-sm text-red-600 dark:text-red-400" role="alert">{error}</p>
63
115
  )}
64
116
  {helperText && !error && (
65
- <p className="text-sm text-gray-500 dark:text-gray-400">
66
- {helperText}
67
- </p>
117
+ <p className="text-sm text-gray-500 dark:text-gray-400">{helperText}</p>
68
118
  )}
69
119
  </div>
70
120
  );
71
- };
121
+ };