@idealyst/components 1.2.127 → 1.2.129

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@idealyst/components",
3
- "version": "1.2.127",
3
+ "version": "1.2.129",
4
4
  "description": "Shared component library for React and React Native",
5
5
  "documentation": "https://github.com/IdealystIO/idealyst-framework/tree/main/packages/components#readme",
6
6
  "readme": "README.md",
@@ -56,7 +56,7 @@
56
56
  "publish:npm": "npm publish"
57
57
  },
58
58
  "peerDependencies": {
59
- "@idealyst/theme": "^1.2.127",
59
+ "@idealyst/theme": "^1.2.129",
60
60
  "@mdi/js": ">=7.0.0",
61
61
  "@mdi/react": ">=1.0.0",
62
62
  "@react-native-vector-icons/common": ">=12.0.0",
@@ -81,10 +81,10 @@
81
81
  "@mdi/react": {
82
82
  "optional": true
83
83
  },
84
- "@react-navigation/bottom-tabs": {
84
+ "@react-native/normalize-colors": {
85
85
  "optional": true
86
86
  },
87
- "@react-native/normalize-colors": {
87
+ "@react-navigation/bottom-tabs": {
88
88
  "optional": true
89
89
  },
90
90
  "react-native": {
@@ -111,8 +111,8 @@
111
111
  },
112
112
  "devDependencies": {
113
113
  "@idealyst/blur": "^1.2.40",
114
- "@idealyst/theme": "^1.2.127",
115
- "@idealyst/tooling": "^1.2.127",
114
+ "@idealyst/theme": "^1.2.129",
115
+ "@idealyst/tooling": "^1.2.129",
116
116
  "@mdi/react": "^1.6.1",
117
117
  "@types/react": "^19.1.0",
118
118
  "react": "^19.1.0",
@@ -187,6 +187,7 @@ function TableInner<T = any>({
187
187
  size = 'md',
188
188
  stickyHeader: _stickyHeader = false,
189
189
  onRowPress,
190
+ emptyState,
190
191
  // Spacing variants from ContainerStyleProps
191
192
  gap,
192
193
  padding,
@@ -255,36 +256,34 @@ function TableInner<T = any>({
255
256
  return column.footer;
256
257
  };
257
258
 
258
- return (
259
- <ScrollView
260
- ref={ref}
261
- nativeID={id}
262
- horizontal
263
- style={[containerStyle, style]}
264
- testID={testID}
265
- {...nativeA11yProps}
266
- >
267
- <View style={tableStyle}>
268
- {/* Header */}
269
- <View style={theadStyle}>
270
- <View style={{ flexDirection: 'row' }}>
271
- {columns.map((column) => (
272
- <TH
273
- key={column.key}
274
- size={size}
275
- type={type}
276
- align={column.align}
277
- width={column.width}
278
- >
279
- {column.title}
280
- </TH>
281
- ))}
282
- </View>
259
+ // Split columns into sticky left, scrollable, and sticky right
260
+ const leftCols = useMemo(() => columns.filter((c) => c.sticky === true || c.sticky === 'left'), [columns]);
261
+ const rightCols = useMemo(() => columns.filter((c) => c.sticky === 'right'), [columns]);
262
+ const scrollCols = useMemo(() => columns.filter((c) => !c.sticky), [columns]);
263
+ const hasStickyColumns = leftCols.length > 0 || rightCols.length > 0;
264
+
265
+ // Renders a column group (header + body + footer) for a set of columns
266
+ const renderColumnGroup = (cols: TableColumn<T>[]) => (
267
+ <View>
268
+ {/* Header */}
269
+ <View style={theadStyle}>
270
+ <View style={{ flexDirection: 'row' }}>
271
+ {cols.map((column) => (
272
+ <TH key={column.key} size={size} type={type} align={column.align} width={column.width}>
273
+ {column.title}
274
+ </TH>
275
+ ))}
283
276
  </View>
277
+ </View>
284
278
 
285
- {/* Body */}
286
- <View style={tbodyStyle}>
287
- {data.map((row, rowIndex) => (
279
+ {/* Body */}
280
+ <View style={tbodyStyle}>
281
+ {data.length === 0 && emptyState ? (
282
+ <View style={{ alignItems: 'center', padding: 16 }}>
283
+ {emptyState}
284
+ </View>
285
+ ) : (
286
+ data.map((row, rowIndex) => (
288
287
  <TR
289
288
  key={rowIndex}
290
289
  size={size}
@@ -293,39 +292,71 @@ function TableInner<T = any>({
293
292
  onPress={() => onRowPress?.(row, rowIndex)}
294
293
  testID={testID ? `${testID}-row-${rowIndex}` : undefined}
295
294
  >
296
- {columns.map((column) => (
297
- <TD
298
- key={column.key}
299
- size={size}
300
- type={type}
301
- align={column.align}
302
- width={column.width}
303
- >
295
+ {cols.map((column) => (
296
+ <TD key={column.key} size={size} type={type} align={column.align} width={column.width}>
304
297
  {getCellValue(column, row, rowIndex)}
305
298
  </TD>
306
299
  ))}
307
300
  </TR>
308
- ))}
301
+ ))
302
+ )}
303
+ </View>
304
+
305
+ {/* Footer */}
306
+ {hasFooter && (
307
+ <View style={(tableStyles.tfoot as any)({})}>
308
+ <View style={{ flexDirection: 'row' }}>
309
+ {cols.map((column) => (
310
+ <TF key={column.key} size={size} type={type} align={column.align} width={column.width}>
311
+ {getFooterContent(column) ?? null}
312
+ </TF>
313
+ ))}
314
+ </View>
309
315
  </View>
316
+ )}
317
+ </View>
318
+ );
310
319
 
311
- {/* Footer */}
312
- {hasFooter && (
313
- <View style={(tableStyles.tfoot as any)({})}>
314
- <View style={{ flexDirection: 'row' }}>
315
- {columns.map((column) => (
316
- <TF
317
- key={column.key}
318
- size={size}
319
- type={type}
320
- align={column.align}
321
- width={column.width}
322
- >
323
- {getFooterContent(column) ?? null}
324
- </TF>
325
- ))}
326
- </View>
320
+ // When there are sticky columns, render as: [left sticky] [scrollable] [right sticky]
321
+ if (hasStickyColumns) {
322
+ return (
323
+ <View
324
+ nativeID={id}
325
+ style={[containerStyle, { flexDirection: 'row' }, style]}
326
+ testID={testID}
327
+ {...nativeA11yProps}
328
+ >
329
+ {leftCols.length > 0 && (
330
+ <View style={{ zIndex: 1, backgroundColor: theadStyle.backgroundColor || containerStyle.backgroundColor }}>
331
+ {renderColumnGroup(leftCols)}
327
332
  </View>
328
333
  )}
334
+ <ScrollView ref={ref} horizontal style={{ flex: 1 }}>
335
+ <View style={tableStyle}>
336
+ {renderColumnGroup(scrollCols)}
337
+ </View>
338
+ </ScrollView>
339
+ {rightCols.length > 0 && (
340
+ <View style={{ zIndex: 1, backgroundColor: theadStyle.backgroundColor || containerStyle.backgroundColor }}>
341
+ {renderColumnGroup(rightCols)}
342
+ </View>
343
+ )}
344
+ </View>
345
+ );
346
+ }
347
+
348
+ // No sticky columns — original simple layout
349
+ return (
350
+ <ScrollView
351
+ ref={ref}
352
+ nativeID={id}
353
+ horizontal
354
+ style={[containerStyle, style]}
355
+ testID={testID}
356
+ {...nativeA11yProps}
357
+ >
358
+ <View style={tableStyle}>
359
+ {renderColumnGroup(columns)}
329
360
  </View>
330
361
  </ScrollView>
331
362
  );
@@ -176,6 +176,7 @@ export const tableStyles = defineStyle('Table', (theme: Theme) => ({
176
176
  },
177
177
  },
178
178
  _web: {
179
+ position: 'relative',
179
180
  borderBottom: `2px solid ${theme.colors.border.primary}`,
180
181
  borderRight: type === 'bordered' ? `1px solid ${theme.colors.border.primary}` : undefined,
181
182
  ':last-child': type === 'bordered' ? { borderRight: 'none' } : {},
@@ -1,9 +1,28 @@
1
- import { useMemo, ReactNode } from 'react';
1
+ import { useMemo, useRef, useCallback, ReactNode } from 'react';
2
2
  import { getWebProps } from 'react-native-unistyles/web';
3
3
  import { tableStyles } from './Table.styles';
4
4
  import type { TableProps, TableColumn, TableType, TableSizeVariant, TableAlignVariant } from './types';
5
5
  import { getWebAriaProps } from '../utils/accessibility';
6
6
 
7
+ // ============================================================================
8
+ // Helpers
9
+ // ============================================================================
10
+
11
+ function getStickyStyle(
12
+ sticky: boolean | 'left' | 'right' | undefined,
13
+ offset: number | string | undefined,
14
+ zIndex: number,
15
+ ): React.CSSProperties | undefined {
16
+ if (!sticky) return undefined;
17
+ const side = sticky === 'right' ? 'right' : 'left';
18
+ return {
19
+ position: 'sticky',
20
+ [side]: offset ?? 0,
21
+ zIndex,
22
+ backgroundColor: 'inherit',
23
+ };
24
+ }
25
+
7
26
  // ============================================================================
8
27
  // Sub-component Props
9
28
  // ============================================================================
@@ -23,6 +42,11 @@ interface THProps {
23
42
  type?: TableType;
24
43
  align?: TableAlignVariant;
25
44
  width?: number | string;
45
+ sticky?: boolean | 'left' | 'right';
46
+ stickyOffset?: number | string;
47
+ resizable?: boolean;
48
+ onResize?: (width: number) => void;
49
+ minWidth?: number;
26
50
  accessibilitySort?: 'ascending' | 'descending' | 'none' | 'other';
27
51
  }
28
52
 
@@ -32,6 +56,8 @@ interface TDProps {
32
56
  type?: TableType;
33
57
  align?: TableAlignVariant;
34
58
  width?: number | string;
59
+ sticky?: boolean | 'left' | 'right';
60
+ stickyOffset?: number | string;
35
61
  }
36
62
 
37
63
  // ============================================================================
@@ -75,6 +101,11 @@ function TH({
75
101
  type = 'standard',
76
102
  align = 'left',
77
103
  width,
104
+ sticky,
105
+ stickyOffset,
106
+ resizable,
107
+ onResize,
108
+ minWidth = 50,
78
109
  accessibilitySort,
79
110
  }: THProps) {
80
111
  tableStyles.useVariants({
@@ -84,15 +115,62 @@ function TH({
84
115
  });
85
116
 
86
117
  const headerCellProps = getWebProps([(tableStyles.headerCell as any)({})]);
118
+ const thRef = useRef<HTMLTableCellElement>(null);
119
+
120
+ const handlePointerDown = useCallback((e: React.PointerEvent) => {
121
+ e.preventDefault();
122
+ e.stopPropagation();
123
+ const th = thRef.current;
124
+ if (!th) return;
125
+
126
+ const startX = e.clientX;
127
+ const startWidth = th.getBoundingClientRect().width;
128
+
129
+ const handlePointerMove = (moveEvent: PointerEvent) => {
130
+ const newWidth = Math.max(minWidth, startWidth + (moveEvent.clientX - startX));
131
+ th.style.width = `${newWidth}px`;
132
+ };
133
+
134
+ const handlePointerUp = (_upEvent: PointerEvent) => {
135
+ document.removeEventListener('pointermove', handlePointerMove);
136
+ document.removeEventListener('pointerup', handlePointerUp);
137
+ const finalWidth = th.getBoundingClientRect().width;
138
+ onResize?.(finalWidth);
139
+ // Remove inline cursor override
140
+ document.body.style.cursor = '';
141
+ document.body.style.userSelect = '';
142
+ };
143
+
144
+ // Prevent text selection and set resize cursor globally during drag
145
+ document.body.style.cursor = 'col-resize';
146
+ document.body.style.userSelect = 'none';
147
+ document.addEventListener('pointermove', handlePointerMove);
148
+ document.addEventListener('pointerup', handlePointerUp);
149
+ }, [minWidth, onResize]);
87
150
 
88
151
  return (
89
152
  <th
90
153
  {...headerCellProps}
154
+ ref={thRef}
91
155
  scope="col"
92
156
  aria-sort={accessibilitySort}
93
- style={{ width }}
157
+ style={{ width, ...getStickyStyle(sticky, stickyOffset, 11) }}
94
158
  >
95
159
  {children}
160
+ {resizable && (
161
+ <span
162
+ onPointerDown={handlePointerDown}
163
+ style={{
164
+ position: 'absolute',
165
+ right: 0,
166
+ top: 0,
167
+ bottom: 0,
168
+ width: 4,
169
+ cursor: 'col-resize',
170
+ userSelect: 'none',
171
+ }}
172
+ />
173
+ )}
96
174
  </th>
97
175
  );
98
176
  }
@@ -107,6 +185,8 @@ function TD({
107
185
  type = 'standard',
108
186
  align = 'left',
109
187
  width,
188
+ sticky,
189
+ stickyOffset,
110
190
  }: TDProps) {
111
191
  tableStyles.useVariants({
112
192
  size,
@@ -119,7 +199,7 @@ function TD({
119
199
  return (
120
200
  <td
121
201
  {...cellProps}
122
- style={{ width }}
202
+ style={{ width, ...getStickyStyle(sticky, stickyOffset, 1) }}
123
203
  >
124
204
  {children}
125
205
  </td>
@@ -136,6 +216,8 @@ interface TFProps {
136
216
  type?: TableType;
137
217
  align?: TableAlignVariant;
138
218
  width?: number | string;
219
+ sticky?: boolean | 'left' | 'right';
220
+ stickyOffset?: number | string;
139
221
  }
140
222
 
141
223
  function TF({
@@ -144,6 +226,8 @@ function TF({
144
226
  type = 'standard',
145
227
  align = 'left',
146
228
  width,
229
+ sticky,
230
+ stickyOffset,
147
231
  }: TFProps) {
148
232
  tableStyles.useVariants({
149
233
  size,
@@ -156,7 +240,7 @@ function TF({
156
240
  return (
157
241
  <td
158
242
  {...footerCellProps}
159
- style={{ width }}
243
+ style={{ width, ...getStickyStyle(sticky, stickyOffset, 1) }}
160
244
  >
161
245
  {children}
162
246
  </td>
@@ -176,8 +260,10 @@ function Table<T = any>({
176
260
  data,
177
261
  type = 'standard',
178
262
  size = 'md',
179
- stickyHeader: _stickyHeader = false,
263
+ stickyHeader = false,
180
264
  onRowPress,
265
+ onColumnResize,
266
+ emptyState,
181
267
  // Spacing variants from ContainerStyleProps
182
268
  gap,
183
269
  padding,
@@ -241,10 +327,39 @@ function Table<T = any>({
241
327
  return column.footer;
242
328
  };
243
329
 
330
+ // Compute cumulative offsets for sticky columns (left and right independently)
331
+ const stickyOffsetMap = useMemo(() => {
332
+ const map = new Map<string, number>();
333
+
334
+ // Left sticky: accumulate left-to-right
335
+ let cumulativeLeft = 0;
336
+ for (const col of columns) {
337
+ const side = col.sticky === 'right' ? 'right' : col.sticky ? 'left' : null;
338
+ if (side !== 'left') continue;
339
+ map.set(col.key, cumulativeLeft);
340
+ if (typeof col.width === 'number') {
341
+ cumulativeLeft += col.width;
342
+ }
343
+ }
344
+
345
+ // Right sticky: accumulate right-to-left
346
+ let cumulativeRight = 0;
347
+ for (let i = columns.length - 1; i >= 0; i--) {
348
+ const col = columns[i];
349
+ if (col.sticky !== 'right') continue;
350
+ map.set(col.key, cumulativeRight);
351
+ if (typeof col.width === 'number') {
352
+ cumulativeRight += col.width;
353
+ }
354
+ }
355
+
356
+ return map;
357
+ }, [columns]);
358
+
244
359
  return (
245
360
  <div {...containerProps} {...ariaProps} id={id} data-testid={testID}>
246
361
  <table {...tableProps} role="table">
247
- <thead {...getWebProps([(tableStyles.thead as any)({})])}>
362
+ <thead {...getWebProps([(tableStyles.thead as any)({ sticky: stickyHeader })])}>
248
363
  <tr>
249
364
  {columns.map((column) => (
250
365
  <TH
@@ -253,6 +368,11 @@ function Table<T = any>({
253
368
  type={type}
254
369
  align={column.align}
255
370
  width={column.width}
371
+ sticky={column.sticky}
372
+ stickyOffset={stickyOffsetMap.get(column.key)}
373
+ resizable={column.resizable}
374
+ minWidth={column.minWidth}
375
+ onResize={onColumnResize ? (w) => onColumnResize(column.key, w) : undefined}
256
376
  accessibilitySort={column.accessibilitySort}
257
377
  >
258
378
  {column.title}
@@ -261,28 +381,38 @@ function Table<T = any>({
261
381
  </tr>
262
382
  </thead>
263
383
  <tbody {...getWebProps([(tableStyles.tbody as any)({})])}>
264
- {data.map((row, rowIndex) => (
265
- <TR
266
- key={rowIndex}
267
- size={size}
268
- type={type}
269
- clickable={isClickable}
270
- onClick={() => onRowPress?.(row, rowIndex)}
271
- testID={testID ? `${testID}-row-${rowIndex}` : undefined}
272
- >
273
- {columns.map((column) => (
274
- <TD
275
- key={column.key}
276
- size={size}
277
- type={type}
278
- align={column.align}
279
- width={column.width}
280
- >
281
- {getCellValue(column, row, rowIndex)}
282
- </TD>
283
- ))}
284
- </TR>
285
- ))}
384
+ {data.length === 0 && emptyState ? (
385
+ <tr>
386
+ <td colSpan={columns.length} style={{ textAlign: 'center' }}>
387
+ {emptyState}
388
+ </td>
389
+ </tr>
390
+ ) : (
391
+ data.map((row, rowIndex) => (
392
+ <TR
393
+ key={rowIndex}
394
+ size={size}
395
+ type={type}
396
+ clickable={isClickable}
397
+ onClick={() => onRowPress?.(row, rowIndex)}
398
+ testID={testID ? `${testID}-row-${rowIndex}` : undefined}
399
+ >
400
+ {columns.map((column) => (
401
+ <TD
402
+ key={column.key}
403
+ size={size}
404
+ type={type}
405
+ align={column.align}
406
+ width={column.width}
407
+ sticky={column.sticky}
408
+ stickyOffset={stickyOffsetMap.get(column.key)}
409
+ >
410
+ {getCellValue(column, row, rowIndex)}
411
+ </TD>
412
+ ))}
413
+ </TR>
414
+ ))
415
+ )}
286
416
  </tbody>
287
417
  {hasFooter && (
288
418
  <tfoot {...getWebProps([(tableStyles.tfoot as any)({})])}>
@@ -294,6 +424,8 @@ function Table<T = any>({
294
424
  type={type}
295
425
  align={column.align}
296
426
  width={column.width}
427
+ sticky={column.sticky}
428
+ stickyOffset={stickyOffsetMap.get(column.key)}
297
429
  >
298
430
  {getFooterContent(column) ?? null}
299
431
  </TF>
@@ -17,6 +17,21 @@ export interface TableColumn<T = any> extends SortableAccessibilityProps {
17
17
  footer?: ReactNode | ((data: T[]) => ReactNode);
18
18
  width?: number | string;
19
19
  align?: TableAlignVariant;
20
+ /**
21
+ * Makes this column sticky (pinned) when scrolling horizontally.
22
+ * `true` or `'left'` pins to the left, `'right'` pins to the right.
23
+ * On web uses CSS `position: sticky`, on native renders outside the ScrollView.
24
+ */
25
+ sticky?: boolean | 'left' | 'right';
26
+ /**
27
+ * Allows the column to be resized by dragging the right edge of the header.
28
+ * Web only.
29
+ */
30
+ resizable?: boolean;
31
+ /**
32
+ * Minimum width when resizing (default: 50).
33
+ */
34
+ minWidth?: number;
20
35
  }
21
36
 
22
37
  /**
@@ -33,6 +48,16 @@ export interface TableProps<T = any> extends ContainerStyleProps, AccessibilityP
33
48
  size?: TableSizeVariant;
34
49
  stickyHeader?: boolean;
35
50
  onRowPress?: (row: T, index: number) => void;
51
+ /**
52
+ * Called when a column is resized via drag handle.
53
+ * Receives the column key and the new width in pixels.
54
+ */
55
+ onColumnResize?: (key: string, width: number) => void;
56
+ /**
57
+ * Content to display when `data` is empty.
58
+ * Renders in place of the table body.
59
+ */
60
+ emptyState?: ReactNode;
36
61
  style?: StyleProp<ViewStyle>;
37
62
  testID?: string;
38
63
  }
@@ -1,13 +1,12 @@
1
- import { useState, forwardRef, useMemo, useEffect, useRef } from 'react';
1
+ import { useState, forwardRef, useMemo, useEffect, useRef, useImperativeHandle } from 'react';
2
2
  import { View, TextInput, NativeSyntheticEvent, TextInputContentSizeChangeEventData, TextInput as RNTextInput } from 'react-native';
3
3
  import { textAreaStyles } from './TextArea.styles';
4
4
  import Text from '../Text';
5
5
  import type { TextAreaProps } from './types';
6
6
  import { getNativeFormAccessibilityProps } from '../utils/accessibility';
7
- import type { IdealystElement } from '../utils/refTypes';
8
- import useMergeRefs from '../hooks/useMergeRefs';
7
+ import type { TextInputHandle } from '../utils/refTypes';
9
8
 
10
- const TextArea = forwardRef<IdealystElement, TextAreaProps>(({
9
+ const TextArea = forwardRef<TextInputHandle, TextAreaProps>(({
11
10
  value: controlledValue,
12
11
  defaultValue = '',
13
12
  onChange,
@@ -52,6 +51,12 @@ const TextArea = forwardRef<IdealystElement, TextAreaProps>(({
52
51
  const [contentHeight, setContentHeight] = useState<number | undefined>(undefined);
53
52
  const textInputRef = useRef<RNTextInput>(null);
54
53
 
54
+ // Expose focus/blur via imperative handle
55
+ useImperativeHandle(ref, () => ({
56
+ focus: () => textInputRef.current?.focus(),
57
+ blur: () => textInputRef.current?.blur(),
58
+ }), []);
59
+
55
60
  const value = controlledValue !== undefined ? controlledValue : internalValue;
56
61
 
57
62
  // When value is cleared externally, trigger a layout recalculation via setNativeProps
@@ -175,7 +180,7 @@ const TextArea = forwardRef<IdealystElement, TextAreaProps>(({
175
180
 
176
181
  <View style={[textareaContainerStyleComputed, fill && { flex: 1 }]}>
177
182
  <TextInput
178
- ref={useMergeRefs(textInputRef, ref as any)}
183
+ ref={textInputRef}
179
184
  {...nativeA11yProps}
180
185
  style={[
181
186
  textareaStyleComputed,
@@ -1,16 +1,16 @@
1
- import React, { useState, useRef, useEffect, forwardRef, useMemo, useCallback } from 'react';
1
+ import React, { useState, useRef, useEffect, forwardRef, useMemo, useCallback, useImperativeHandle } from 'react';
2
2
  import { getWebProps } from 'react-native-unistyles/web';
3
3
  import { textAreaStyles } from './TextArea.styles';
4
4
  import type { TextAreaProps } from './types';
5
5
  import useMergeRefs from '../hooks/useMergeRefs';
6
6
  import { getWebFormAriaProps, combineIds, generateAccessibilityId } from '../utils/accessibility';
7
- import type { IdealystElement } from '../utils/refTypes';
7
+ import type { TextInputHandle } from '../utils/refTypes';
8
8
 
9
9
  /**
10
10
  * Multi-line text input with auto-grow, character counting, and validation support.
11
11
  * Includes label, helper text, and error message display.
12
12
  */
13
- const TextArea = forwardRef<IdealystElement, TextAreaProps>(({
13
+ const TextArea = forwardRef<TextInputHandle, TextAreaProps>(({
14
14
  value: controlledValue,
15
15
  defaultValue = '',
16
16
  onChange,
@@ -61,6 +61,12 @@ const TextArea = forwardRef<IdealystElement, TextAreaProps>(({
61
61
  const [isFocused, setIsFocused] = useState(false);
62
62
  const textareaRef = useRef<HTMLTextAreaElement>(null);
63
63
 
64
+ // Expose focus/blur via imperative handle
65
+ useImperativeHandle(ref, () => ({
66
+ focus: () => textareaRef.current?.focus(),
67
+ blur: () => textareaRef.current?.blur(),
68
+ }), []);
69
+
64
70
  const value = controlledValue !== undefined ? controlledValue : internalValue;
65
71
  const hasError = Boolean(error);
66
72
 
@@ -240,11 +246,10 @@ const TextArea = forwardRef<IdealystElement, TextAreaProps>(({
240
246
  overflowStyle,
241
247
  ].filter(Boolean));
242
248
 
243
- const mergedRef = useMergeRefs(ref, containerProps.ref);
244
249
  const mergedTextareaRef = useMergeRefs(textareaRef, computedTextareaProps.ref);
245
250
 
246
251
  return (
247
- <div {...containerProps} ref={mergedRef} id={id} data-testid={testID} style={{ ...containerProps.style as any, ...(fill ? { flex: 1, display: 'flex', flexDirection: 'column' } : {}) }}>
252
+ <div {...containerProps} id={id} data-testid={testID} style={{ ...(containerProps as any).style, ...(fill ? { flex: 1, display: 'flex', flexDirection: 'column' } : {}) }}>
248
253
  {label && (
249
254
  <label {...labelProps} id={labelId} htmlFor={textareaId}>{label}</label>
250
255
  )}
@@ -1,11 +1,11 @@
1
- import React, { useState, isValidElement, useMemo, useEffect, useRef, useCallback } from 'react';
1
+ import React, { useState, isValidElement, useMemo, useEffect, useRef, useCallback, useImperativeHandle } from 'react';
2
2
  import { View, TextInput as RNTextInput, TouchableOpacity, Platform, TextInputProps as RNTextInputProps } from 'react-native';
3
3
  import MaterialDesignIcons from '@react-native-vector-icons/material-design-icons';
4
4
  import { useUnistyles } from 'react-native-unistyles';
5
5
  import { TextInputProps } from './types';
6
6
  import { textInputStyles } from './TextInput.styles';
7
7
  import { getNativeFormAccessibilityProps } from '../utils/accessibility';
8
- import type { IdealystElement } from '../utils/refTypes';
8
+ import type { TextInputHandle } from '../utils/refTypes';
9
9
  import Text from '../Text';
10
10
 
11
11
  // Inner TextInput component that can be memoized to prevent re-renders
@@ -52,7 +52,7 @@ const InnerRNTextInput = React.memo<InnerTextInputProps>(
52
52
  }
53
53
  );
54
54
 
55
- const TextInput = React.forwardRef<IdealystElement, TextInputProps>(({
55
+ const TextInput = React.forwardRef<TextInputHandle, TextInputProps>(({
56
56
  value,
57
57
  onChangeText,
58
58
  onFocus,
@@ -94,6 +94,13 @@ const TextInput = React.forwardRef<IdealystElement, TextInputProps>(({
94
94
  }, ref) => {
95
95
  const [isFocused, setIsFocused] = useState(false);
96
96
  const [isPasswordVisible, setIsPasswordVisible] = useState(false);
97
+ const inputRef = useRef<RNTextInput>(null);
98
+
99
+ // Expose focus/blur via imperative handle
100
+ useImperativeHandle(ref, () => ({
101
+ focus: () => inputRef.current?.focus(),
102
+ blur: () => inputRef.current?.blur(),
103
+ }), []);
97
104
 
98
105
  // Derive hasError from error prop or hasError boolean
99
106
  const computedHasError = Boolean(error) || hasError;
@@ -311,7 +318,7 @@ const TextInput = React.forwardRef<IdealystElement, TextInputProps>(({
311
318
 
312
319
  {/* Input */}
313
320
  <InnerRNTextInput
314
- inputRef={ref}
321
+ inputRef={inputRef}
315
322
  value={value}
316
323
  onChangeText={handleChangeText}
317
324
  isAndroidSecure={needsAndroidSecureWorkaround}
@@ -359,7 +366,7 @@ const TextInput = React.forwardRef<IdealystElement, TextInputProps>(({
359
366
 
360
367
  {/* Input */}
361
368
  <InnerRNTextInput
362
- inputRef={ref}
369
+ inputRef={inputRef}
363
370
  value={value}
364
371
  onChangeText={handleChangeText}
365
372
  isAndroidSecure={needsAndroidSecureWorkaround}
@@ -1,4 +1,4 @@
1
- import React, { isValidElement, useState, useMemo, useRef } from 'react';
1
+ import React, { isValidElement, useState, useMemo, useRef, useImperativeHandle } from 'react';
2
2
  import { getWebProps } from 'react-native-unistyles/web';
3
3
  import { useUnistyles } from 'react-native-unistyles';
4
4
  import { IconSvg } from '../Icon/IconSvg/IconSvg.web';
@@ -7,14 +7,14 @@ import useMergeRefs from '../hooks/useMergeRefs';
7
7
  import { textInputStyles } from './TextInput.styles';
8
8
  import { TextInputProps } from './types';
9
9
  import { getWebFormAriaProps, combineIds, generateAccessibilityId } from '../utils/accessibility';
10
- import type { IdealystElement } from '../utils/refTypes';
10
+ import type { TextInputHandle } from '../utils/refTypes';
11
11
  import { flattenStyle } from '../utils/flattenStyle';
12
12
 
13
13
  /**
14
14
  * Single-line text input field with support for icons, password visibility toggle, and validation states.
15
15
  * Available in outlined and filled variants with multiple sizes.
16
16
  */
17
- const TextInput = React.forwardRef<IdealystElement, TextInputProps>(({
17
+ const TextInput = React.forwardRef<TextInputHandle, TextInputProps>(({
18
18
  value,
19
19
  onChangeText,
20
20
  onFocus,
@@ -234,8 +234,15 @@ const TextInput = React.forwardRef<IdealystElement, TextInputProps>(({
234
234
  accessibilityAutoComplete,
235
235
  ]);
236
236
 
237
- // Merge the forwarded ref with unistyles ref for the input
238
- const mergedInputRef = useMergeRefs(ref, inputWebProps.ref);
237
+ // Internal ref for the actual <input> element
238
+ const inputRef = useRef<HTMLInputElement>(null);
239
+ const mergedInputRef = useMergeRefs(inputRef, inputWebProps.ref);
240
+
241
+ // Expose focus/blur via imperative handle
242
+ useImperativeHandle(ref, () => ({
243
+ focus: () => inputRef.current?.focus(),
244
+ blur: () => inputRef.current?.blur(),
245
+ }), []);
239
246
 
240
247
  // Helper to render left icon
241
248
  const renderLeftIcon = () => {
package/src/index.ts CHANGED
@@ -210,6 +210,7 @@ export * from './utils/buildViewStyleVariants';
210
210
  // Cross-platform ref types
211
211
  export type {
212
212
  IdealystElement,
213
+ TextInputHandle,
213
214
  AnchorElement,
214
215
  AnchorRef,
215
216
  ComponentElement,
@@ -44,6 +44,24 @@ export type CrossPlatformElement = IdealystElement;
44
44
  */
45
45
  export type CrossPlatformRef = React.RefObject<IdealystElement>;
46
46
 
47
+ /**
48
+ * Imperative handle for text input components (TextInput, TextArea).
49
+ * Provides cross-platform focus/blur control via refs.
50
+ *
51
+ * @example
52
+ * ```tsx
53
+ * const inputRef = React.useRef<TextInputHandle>(null);
54
+ * inputRef.current?.focus();
55
+ * inputRef.current?.blur();
56
+ * ```
57
+ */
58
+ export interface TextInputHandle {
59
+ /** Programmatically focus the input */
60
+ focus: () => void;
61
+ /** Programmatically blur the input */
62
+ blur: () => void;
63
+ }
64
+
47
65
  // Legacy exports kept for backwards compatibility
48
66
  export type WebElement = IdealystElement;
49
67
  export type NativeElement = IdealystElement;