neogestify-ui-components 2.2.2 → 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 +101 -22
  14. package/dist/components/html/index.d.ts +101 -22
  15. package/dist/components/html/index.js +506 -166
  16. package/dist/components/html/index.js.map +1 -1
  17. package/dist/components/html/index.mjs +507 -167
  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 +510 -166
  32. package/dist/index.js.map +1 -1
  33. package/dist/index.mjs +508 -168
  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 +274 -50
  43. package/src/components/html/TextArea.tsx +81 -31
  44. package/src/components/icons/icons.tsx +32 -0
@@ -1,8 +1,24 @@
1
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';
2
14
 
3
- type TableVariant = 'default' | 'striped' | 'bordered' | 'minimal' | 'custom';
4
15
  type TableSize = 'sm' | 'md' | 'lg';
5
16
 
17
+ interface SortState {
18
+ key: string;
19
+ direction: 'asc' | 'desc';
20
+ }
21
+
6
22
  interface ColumnDef {
7
23
  /** Contenido del encabezado */
8
24
  header: ReactNode;
@@ -10,67 +26,105 @@ interface ColumnDef {
10
26
  className?: string;
11
27
  /** Alinear el contenido de esta columna */
12
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;
13
43
  }
14
44
 
15
45
  interface TableProps {
16
46
  /**
17
- * Definición de columnas con encabezado y opciones por columna.
18
- * Si pasas strings simples, los usa como encabezados sin configuración extra.
47
+ * Definición de columnas. Acepta strings simples o ColumnDef para
48
+ * configuración avanzada (ancho, sticky, sort, etc.).
19
49
  */
20
50
  columns: (ColumnDef | ReactNode)[];
21
51
 
22
- /** Filas de la tabla. Cada fila es un arreglo de celdas. */
52
+ /** Filas del cuerpo. Cada fila es un arreglo de celdas. */
23
53
  rows: ReactNode[][];
24
54
 
25
- /** Estilo visual de la tabla. Default: 'default' */
55
+ /** Estilo visual. Default: 'default' */
26
56
  variant?: TableVariant;
27
57
 
28
- /** Tamaño de padding de celdas. Default: 'md' */
58
+ /** Tamaño de padding. Default: 'md' */
29
59
  size?: TableSize;
30
60
 
31
- /** Clase CSS adicional para el wrapper `<div>` externo */
61
+ /** Clase CSS adicional para el wrapper <div> */
32
62
  className?: string;
33
63
 
34
- /** Clase CSS adicional para el `<table>` */
64
+ /** Clase CSS adicional para el <table> */
35
65
  tableClassName?: string;
36
66
 
37
- /** Clase CSS adicional para cada `<th>` */
67
+ /** Clase CSS adicional para cada <th> */
38
68
  thClassName?: string;
39
69
 
40
- /** Clase CSS adicional para cada `<td>` */
70
+ /** Clase CSS adicional para cada <td> */
41
71
  tdClassName?: string;
42
72
 
43
- /** Clase CSS adicional para cada `<tr>` del body */
73
+ /** Clase CSS adicional por fila del body */
44
74
  trClassName?: string | ((rowIndex: number) => string);
45
75
 
46
- /** Texto o nodo a mostrar cuando `rows` está vacío */
76
+ /** Nodo a mostrar cuando rows está vacío */
47
77
  emptyState?: ReactNode;
48
78
 
49
79
  /** Callback al hacer click en una fila */
50
80
  onRowClick?: (rowIndex: number) => void;
51
81
 
52
- /** Si true, no muestra thead */
82
+ /** Oculta el thead */
53
83
  hideHeader?: boolean;
54
84
 
55
- /** Estilos inline para el `<table>` */
85
+ /** Estilos inline para el <table> */
56
86
  style?: CSSProperties;
57
- }
58
87
 
59
- // ─── Helpers ─────────────────────────────────────────────────────────────────
88
+ /** Fija el thead al hacer scroll vertical */
89
+ stickyHeader?: boolean;
60
90
 
61
- function isColumnDef(col: ColumnDef | ReactNode): col is ColumnDef {
62
- return typeof col === 'object' && col !== null && 'header' in (col as object);
63
- }
91
+ /** Caption accesible de la tabla */
92
+ caption?: ReactNode;
64
93
 
65
- function resolveColumn(col: ColumnDef | ReactNode): ColumnDef {
66
- if (isColumnDef(col)) return col;
67
- return { header: col };
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;
68
120
  }
69
121
 
122
+ // ─── Lookup tables ────────────────────────────────────────────────────────────
123
+
70
124
  const ALIGN_CLASS: Record<string, string> = {
71
- left: 'text-left',
125
+ left: 'text-left',
72
126
  center: 'text-center',
73
- right: 'text-right',
127
+ right: 'text-right',
74
128
  };
75
129
 
76
130
  const SIZE_TH: Record<TableSize, string> = {
@@ -90,6 +144,10 @@ const VARIANT_TABLE: Record<TableVariant, string> = {
90
144
  striped: 'w-full min-w-full table-auto',
91
145
  bordered: 'w-full min-w-full table-auto border border-gray-300 dark:border-gray-600',
92
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',
93
151
  custom: 'w-full min-w-full table-auto',
94
152
  };
95
153
 
@@ -98,6 +156,10 @@ const VARIANT_THEAD: Record<TableVariant, string> = {
98
156
  striped: 'bg-gray-100 dark:bg-gray-700',
99
157
  bordered: 'bg-gray-100 dark:bg-gray-700',
100
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',
101
163
  custom: '',
102
164
  };
103
165
 
@@ -106,14 +168,49 @@ const VARIANT_TH: Record<TableVariant, string> = {
106
168
  striped: 'font-semibold uppercase tracking-wider text-gray-600 dark:text-gray-300',
107
169
  bordered: 'font-semibold uppercase tracking-wider text-gray-600 dark:text-gray-300 border border-gray-300 dark:border-gray-600',
108
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',
109
175
  custom: '',
110
176
  };
111
177
 
112
- const VARIANT_TR: Record<TableVariant, (i: number) => string> = {
113
- default: () => 'bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700/60 transition-colors',
114
- striped: (i) => `${i % 2 === 0 ? 'bg-white dark:bg-gray-800' : 'bg-gray-50 dark:bg-gray-700/40'} hover:bg-blue-50 dark:hover:bg-blue-900/20 transition-colors`,
115
- bordered: () => 'bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700/60 transition-colors',
116
- minimal: () => 'hover:bg-gray-50 dark:hover:bg-gray-800/60 transition-colors',
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: () => '',
117
214
  custom: () => '',
118
215
  };
119
216
 
@@ -122,6 +219,10 @@ const VARIANT_TD: Record<TableVariant, string> = {
122
219
  striped: 'text-gray-700 dark:text-gray-300',
123
220
  bordered: 'text-gray-700 dark:text-gray-300 border border-gray-200 dark:border-gray-700',
124
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',
125
226
  custom: '',
126
227
  };
127
228
 
@@ -130,9 +231,51 @@ const VARIANT_TBODY_DIVIDER: Record<TableVariant, string> = {
130
231
  striped: '',
131
232
  bordered: '',
132
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',
133
238
  custom: '',
134
239
  };
135
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
+
136
279
  // ─── Component ───────────────────────────────────────────────────────────────
137
280
 
138
281
  export function Table({
@@ -149,48 +292,96 @@ export function Table({
149
292
  onRowClick,
150
293
  hideHeader = false,
151
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,
152
306
  }: TableProps) {
153
307
  const cols = columns.map(resolveColumn);
154
308
 
155
309
  const resolvedTrClass = (i: number): string => {
156
- const variantCls = VARIANT_TR[variant](i);
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` : '';
157
313
  const clickCls = onRowClick ? 'cursor-pointer' : '';
158
314
  const customCls = typeof trClassName === 'function' ? trClassName(i) : (trClassName ?? '');
159
- return `${variantCls} ${clickCls} ${customCls}`.trim();
315
+ return [baseCls, hoverCls, clickCls, customCls].filter(Boolean).join(' ');
160
316
  };
161
317
 
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(' ');
324
+
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';
331
+
162
332
  return (
163
- <div className={`overflow-x-auto w-full ${className}`.trim()}>
333
+ <div className={wrapperCls}>
164
334
  <table
165
335
  className={`${VARIANT_TABLE[variant]} ${tableClassName}`.trim()}
166
336
  style={style}
167
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
+
168
344
  {!hideHeader && (
169
- <thead className={VARIANT_THEAD[variant]}>
345
+ <thead className={theadCls}>
170
346
  <tr>
171
- {cols.map((col, i) => (
172
- <th
173
- key={i}
174
- className={[
175
- SIZE_TH[size],
176
- VARIANT_TH[variant],
177
- ALIGN_CLASS[col.align ?? 'left'],
178
- col.className ?? '',
179
- thClassName,
180
- ].filter(Boolean).join(' ')}
181
- >
182
- {col.header}
183
- </th>
184
- ))}
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
+ })}
185
371
  </tr>
186
372
  </thead>
187
373
  )}
374
+
188
375
  <tbody className={VARIANT_TBODY_DIVIDER[variant]}>
189
- {rows.length === 0 ? (
376
+ {loading ? (
377
+ Array.from({ length: loadingRows }).map((_, i) => (
378
+ <SkeletonRow key={i} colCount={cols.length} size={size} />
379
+ ))
380
+ ) : rows.length === 0 ? (
190
381
  <tr>
191
382
  <td
192
383
  colSpan={cols.length}
193
- className={`${SIZE_TD[size]} text-center text-gray-400 dark:text-gray-500 py-8`}
384
+ className={`${SIZE_TD[size]} py-8 text-center text-gray-400 dark:text-gray-500`}
194
385
  >
195
386
  {emptyState ?? 'Sin datos'}
196
387
  </td>
@@ -200,6 +391,7 @@ export function Table({
200
391
  <tr
201
392
  key={rowIndex}
202
393
  className={resolvedTrClass(rowIndex)}
394
+ style={getRowStyle?.(rowIndex)}
203
395
  onClick={onRowClick ? () => onRowClick(rowIndex) : undefined}
204
396
  >
205
397
  {row.map((cell, cellIndex) => {
@@ -213,7 +405,12 @@ export function Table({
213
405
  ALIGN_CLASS[col?.align ?? 'left'],
214
406
  col?.className ?? '',
215
407
  tdClassName,
408
+ col?.sticky ? stickyColCls : '',
216
409
  ].filter(Boolean).join(' ')}
410
+ style={{
411
+ ...(col ? colSizeStyle(col) : {}),
412
+ ...(col?.tdStyle ?? {}),
413
+ }}
217
414
  >
218
415
  {cell}
219
416
  </td>
@@ -223,7 +420,34 @@ export function Table({
223
420
  ))
224
421
  )}
225
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
+ )}
226
450
  </table>
227
451
  </div>
228
452
  );
229
- }
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
+ };
@@ -687,4 +687,36 @@ export function IconLayers({ className }: Props) {
687
687
  <path d="M8.235 1.559a.5.5 0 0 0-.47 0l-7.5 4a.5.5 0 0 0 0 .882L3.188 8 .265 9.559a.5.5 0 0 0 0 .882l7.5 4a.5.5 0 0 0 .47 0l7.5-4a.5.5 0 0 0 0-.882L12.813 8l2.922-1.559a.5.5 0 0 0 0-.882l-7.5-4zm3.515 7.008L14.438 10 8 13.433 1.562 10 4.25 8.567l3.515 1.874a.5.5 0 0 0 .47 0l3.515-1.874zM8 9.433 1.562 6 8 2.567 14.438 6 8 9.433z"/>
688
688
  </svg>
689
689
  );
690
+ }
691
+
692
+ export function ChevronDownIcon({ className }: Props) {
693
+ return (
694
+ <svg viewBox="0 0 16 16" className={className} fill="currentColor" aria-hidden>
695
+ <path d="M4.516 7.548c.436-.446 1.043-.481 1.576 0L8 9.747l1.908-2.199c.533-.481 1.14-.446 1.576 0 .436.445.408 1.197 0 1.615-.408.418-2.695 2.977-2.695 2.977-.27.282-.64.423-1.01.423s-.74-.14-1.01-.423c0 0-2.287-2.559-2.695-2.977-.408-.418-.436-1.17 0-1.615z" />
696
+ </svg>
697
+ );
698
+ }
699
+
700
+ export function SortAscIcon({ className }: Props) {
701
+ return (
702
+ <svg viewBox="0 0 12 12" className={className} fill="currentColor" aria-hidden>
703
+ <path d="M6 2.5l4 5H2l4-5z" />
704
+ </svg>
705
+ );
706
+ }
707
+
708
+ export function SortDescIcon({ className }: Props) {
709
+ return (
710
+ <svg viewBox="0 0 12 12" className={className} fill="currentColor" aria-hidden>
711
+ <path d="M6 9.5L2 4.5h8L6 9.5z" />
712
+ </svg>
713
+ );
714
+ }
715
+
716
+ export function SortBothIcon({ className }: Props) {
717
+ return (
718
+ <svg viewBox="0 0 12 12" className={className} fill="currentColor" aria-hidden>
719
+ <path d="M6 1.5l3 3.5H3L6 1.5zm0 9l-3-3.5h6L6 10.5z" />
720
+ </svg>
721
+ );
690
722
  }