@idealyst/components 1.2.126 → 1.2.128

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.126",
3
+ "version": "1.2.128",
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.126",
59
+ "@idealyst/theme": "^1.2.128",
60
60
  "@mdi/js": ">=7.0.0",
61
61
  "@mdi/react": ">=1.0.0",
62
62
  "@react-native-vector-icons/common": ">=12.0.0",
@@ -111,8 +111,8 @@
111
111
  },
112
112
  "devDependencies": {
113
113
  "@idealyst/blur": "^1.2.40",
114
- "@idealyst/theme": "^1.2.126",
115
- "@idealyst/tooling": "^1.2.126",
114
+ "@idealyst/theme": "^1.2.128",
115
+ "@idealyst/tooling": "^1.2.128",
116
116
  "@mdi/react": "^1.6.1",
117
117
  "@types/react": "^19.1.0",
118
118
  "react": "^19.1.0",
@@ -173,7 +173,6 @@ const TabBar = forwardRef<IdealystElement, TabBarProps>(({
173
173
  // Apply icon variants (size, disabled, iconPosition)
174
174
  tabBarIconStyles.useVariants({
175
175
  size,
176
- active: isActive,
177
176
  disabled: Boolean(item.disabled),
178
177
  iconPosition,
179
178
  });
@@ -181,7 +180,6 @@ const TabBar = forwardRef<IdealystElement, TabBarProps>(({
181
180
  // Compute dynamic styles for this tab - call as functions for theme reactivity
182
181
  const tabStyle = (tabBarTabStyles.tab as any)({ type, size, active: isActive, pillMode, justify });
183
182
  const labelStyle = (tabBarLabelStyles.tabLabel as any)({ type, active: isActive, pillMode });
184
- const iconContainerStyle = (tabBarIconStyles.tabIcon as any)({});
185
183
 
186
184
  const icon = renderIcon(item.icon, isActive, iconSize);
187
185
 
@@ -201,7 +199,7 @@ const TabBar = forwardRef<IdealystElement, TabBarProps>(({
201
199
  accessibilityLabel={item.label}
202
200
  accessibilityState={{ selected: isActive, disabled: item.disabled }}
203
201
  >
204
- {icon && <View style={iconContainerStyle}>{icon}</View>}
202
+ {icon && <View style={tabBarIconStyles.tabIcon as any}>{icon}</View>}
205
203
  <Text style={labelStyle}>{item.label}</Text>
206
204
  </TouchableOpacity>
207
205
  );
@@ -92,15 +92,24 @@ export const tabBarStyles = defineStyle('TabBar', (theme: Theme) => ({
92
92
  },
93
93
 
94
94
  tab: ({ type = 'standard', size = 'md', active = false, pillMode: _pillMode = 'light', disabled = false, iconPosition = 'left', justify = 'start' }: TabBarDynamicProps) => {
95
- // Tab padding for pillsnarrower than standard tabs
96
- const pillsPaddingMap: Record<Size, { paddingVertical: number; paddingHorizontal: number }> = {
97
- xs: { paddingVertical: 2, paddingHorizontal: 8 },
98
- sm: { paddingVertical: 3, paddingHorizontal: 10 },
99
- md: { paddingVertical: 4, paddingHorizontal: 12 },
100
- lg: { paddingVertical: 6, paddingHorizontal: 16 },
101
- xl: { paddingVertical: 8, paddingHorizontal: 20 },
95
+ // Resolve padding at runtimecan't use $iterator with runtime `type` check
96
+ // Use explicit top/bottom/left/right for cross-platform compatibility
97
+ const pillsPaddingMap: Record<Size, { paddingTop: number; paddingBottom: number; paddingLeft: number; paddingRight: number }> = {
98
+ xs: { paddingTop: 2, paddingBottom: 2, paddingLeft: 8, paddingRight: 8 },
99
+ sm: { paddingTop: 3, paddingBottom: 3, paddingLeft: 10, paddingRight: 10 },
100
+ md: { paddingTop: 4, paddingBottom: 4, paddingLeft: 12, paddingRight: 12 },
101
+ lg: { paddingTop: 6, paddingBottom: 6, paddingLeft: 16, paddingRight: 16 },
102
+ xl: { paddingTop: 8, paddingBottom: 8, paddingLeft: 20, paddingRight: 20 },
102
103
  };
103
- const tabPadding = type === 'pills' ? pillsPaddingMap[size] : {};
104
+ const sizeValues = theme.sizes.tabBar[size];
105
+ const tabPadding = type === 'pills'
106
+ ? pillsPaddingMap[size]
107
+ : {
108
+ paddingTop: sizeValues.padding,
109
+ paddingBottom: sizeValues.padding,
110
+ paddingLeft: sizeValues.padding,
111
+ paddingRight: sizeValues.padding,
112
+ };
104
113
 
105
114
  // Color based on type and active state
106
115
  let color = active ? theme.colors.text.primary : theme.colors.text.secondary;
@@ -128,9 +137,6 @@ export const tabBarStyles = defineStyle('TabBar', (theme: Theme) => ({
128
137
  size: {
129
138
  fontSize: theme.sizes.$tabBar.fontSize,
130
139
  lineHeight: theme.sizes.$tabBar.lineHeight,
131
- paddingTop: type === 'pills' ? undefined : theme.sizes.$tabBar.padding,
132
- paddingBottom: type === 'pills' ? undefined : theme.sizes.$tabBar.paddingBottom,
133
- paddingHorizontal: type === 'pills' ? undefined : theme.sizes.$tabBar.padding,
134
140
  },
135
141
  },
136
142
  _web: {
@@ -165,19 +171,25 @@ export const tabBarStyles = defineStyle('TabBar', (theme: Theme) => ({
165
171
  } as const;
166
172
  },
167
173
 
168
- tabIcon: ({ active: _active = false, disabled = false, iconPosition = 'left' }: TabBarDynamicProps) => ({
174
+ tabIcon: {
169
175
  display: 'flex' as const,
170
176
  alignItems: 'center' as const,
171
177
  justifyContent: 'center' as const,
172
- opacity: disabled ? 0.5 : 1,
173
- marginBottom: iconPosition === 'top' ? 2 : 0,
174
178
  variants: {
175
179
  size: {
176
180
  width: theme.sizes.$tabBar.fontSize,
177
181
  height: theme.sizes.$tabBar.fontSize,
178
182
  },
183
+ disabled: {
184
+ true: { opacity: 0.5 },
185
+ false: { opacity: 1 },
186
+ },
187
+ iconPosition: {
188
+ top: { marginBottom: 2 },
189
+ left: { marginBottom: 0 },
190
+ },
179
191
  },
180
- }),
192
+ },
181
193
 
182
194
  indicator: ({ type = 'standard', pillMode = 'light' }: TabBarDynamicProps) => {
183
195
  const backgroundColor = type === 'pills'
@@ -186,8 +198,8 @@ export const tabBarStyles = defineStyle('TabBar', (theme: Theme) => ({
186
198
 
187
199
  const typeStyles = type === 'pills' ? {
188
200
  borderRadius: 9999,
189
- bottom: 3,
190
- top: 3,
201
+ top: 4,
202
+ bottom: 4,
191
203
  left: 0,
192
204
  } : {
193
205
  bottom: -1,
@@ -79,7 +79,6 @@ const Tab: React.FC<TabProps> = ({
79
79
  // Apply icon variants (size, disabled, iconPosition)
80
80
  tabBarIconStyles.useVariants({
81
81
  size,
82
- active: isActive,
83
82
  disabled: Boolean(item.disabled),
84
83
  iconPosition,
85
84
  });
@@ -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,
@@ -284,28 +285,34 @@ function TableInner<T = any>({
284
285
 
285
286
  {/* Body */}
286
287
  <View style={tbodyStyle}>
287
- {data.map((row, rowIndex) => (
288
- <TR
289
- key={rowIndex}
290
- size={size}
291
- type={type}
292
- clickable={isClickable}
293
- onPress={() => onRowPress?.(row, rowIndex)}
294
- testID={testID ? `${testID}-row-${rowIndex}` : undefined}
295
- >
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
- >
304
- {getCellValue(column, row, rowIndex)}
305
- </TD>
306
- ))}
307
- </TR>
308
- ))}
288
+ {data.length === 0 && emptyState ? (
289
+ <View style={{ alignItems: 'center', padding: 16 }}>
290
+ {emptyState}
291
+ </View>
292
+ ) : (
293
+ data.map((row, rowIndex) => (
294
+ <TR
295
+ key={rowIndex}
296
+ size={size}
297
+ type={type}
298
+ clickable={isClickable}
299
+ onPress={() => onRowPress?.(row, rowIndex)}
300
+ testID={testID ? `${testID}-row-${rowIndex}` : undefined}
301
+ >
302
+ {columns.map((column) => (
303
+ <TD
304
+ key={column.key}
305
+ size={size}
306
+ type={type}
307
+ align={column.align}
308
+ width={column.width}
309
+ >
310
+ {getCellValue(column, row, rowIndex)}
311
+ </TD>
312
+ ))}
313
+ </TR>
314
+ ))
315
+ )}
309
316
  </View>
310
317
 
311
318
  {/* Footer */}
@@ -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,4 +1,4 @@
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';
@@ -23,6 +23,11 @@ interface THProps {
23
23
  type?: TableType;
24
24
  align?: TableAlignVariant;
25
25
  width?: number | string;
26
+ sticky?: boolean;
27
+ stickyLeft?: number | string;
28
+ resizable?: boolean;
29
+ onResize?: (width: number) => void;
30
+ minWidth?: number;
26
31
  accessibilitySort?: 'ascending' | 'descending' | 'none' | 'other';
27
32
  }
28
33
 
@@ -32,6 +37,8 @@ interface TDProps {
32
37
  type?: TableType;
33
38
  align?: TableAlignVariant;
34
39
  width?: number | string;
40
+ sticky?: boolean;
41
+ stickyLeft?: number | string;
35
42
  }
36
43
 
37
44
  // ============================================================================
@@ -75,6 +82,11 @@ function TH({
75
82
  type = 'standard',
76
83
  align = 'left',
77
84
  width,
85
+ sticky,
86
+ stickyLeft,
87
+ resizable,
88
+ onResize,
89
+ minWidth = 50,
78
90
  accessibilitySort,
79
91
  }: THProps) {
80
92
  tableStyles.useVariants({
@@ -84,15 +96,69 @@ function TH({
84
96
  });
85
97
 
86
98
  const headerCellProps = getWebProps([(tableStyles.headerCell as any)({})]);
99
+ const thRef = useRef<HTMLTableCellElement>(null);
100
+
101
+ const handlePointerDown = useCallback((e: React.PointerEvent) => {
102
+ e.preventDefault();
103
+ e.stopPropagation();
104
+ const th = thRef.current;
105
+ if (!th) return;
106
+
107
+ const startX = e.clientX;
108
+ const startWidth = th.getBoundingClientRect().width;
109
+
110
+ const handlePointerMove = (moveEvent: PointerEvent) => {
111
+ const newWidth = Math.max(minWidth, startWidth + (moveEvent.clientX - startX));
112
+ th.style.width = `${newWidth}px`;
113
+ };
114
+
115
+ const handlePointerUp = (_upEvent: PointerEvent) => {
116
+ document.removeEventListener('pointermove', handlePointerMove);
117
+ document.removeEventListener('pointerup', handlePointerUp);
118
+ const finalWidth = th.getBoundingClientRect().width;
119
+ onResize?.(finalWidth);
120
+ // Remove inline cursor override
121
+ document.body.style.cursor = '';
122
+ document.body.style.userSelect = '';
123
+ };
124
+
125
+ // Prevent text selection and set resize cursor globally during drag
126
+ document.body.style.cursor = 'col-resize';
127
+ document.body.style.userSelect = 'none';
128
+ document.addEventListener('pointermove', handlePointerMove);
129
+ document.addEventListener('pointerup', handlePointerUp);
130
+ }, [minWidth, onResize]);
131
+
132
+ const stickyStyle: React.CSSProperties | undefined = sticky ? {
133
+ position: 'sticky',
134
+ left: stickyLeft ?? 0,
135
+ zIndex: 11,
136
+ backgroundColor: 'inherit',
137
+ } : undefined;
87
138
 
88
139
  return (
89
140
  <th
90
141
  {...headerCellProps}
142
+ ref={thRef}
91
143
  scope="col"
92
144
  aria-sort={accessibilitySort}
93
- style={{ width }}
145
+ style={{ width, ...stickyStyle }}
94
146
  >
95
147
  {children}
148
+ {resizable && (
149
+ <span
150
+ onPointerDown={handlePointerDown}
151
+ style={{
152
+ position: 'absolute',
153
+ right: 0,
154
+ top: 0,
155
+ bottom: 0,
156
+ width: 4,
157
+ cursor: 'col-resize',
158
+ userSelect: 'none',
159
+ }}
160
+ />
161
+ )}
96
162
  </th>
97
163
  );
98
164
  }
@@ -107,6 +173,8 @@ function TD({
107
173
  type = 'standard',
108
174
  align = 'left',
109
175
  width,
176
+ sticky,
177
+ stickyLeft,
110
178
  }: TDProps) {
111
179
  tableStyles.useVariants({
112
180
  size,
@@ -116,10 +184,17 @@ function TD({
116
184
 
117
185
  const cellProps = getWebProps([(tableStyles.cell as any)({})]);
118
186
 
187
+ const stickyStyle: React.CSSProperties | undefined = sticky ? {
188
+ position: 'sticky',
189
+ left: stickyLeft ?? 0,
190
+ zIndex: 1,
191
+ backgroundColor: 'inherit',
192
+ } : undefined;
193
+
119
194
  return (
120
195
  <td
121
196
  {...cellProps}
122
- style={{ width }}
197
+ style={{ width, ...stickyStyle }}
123
198
  >
124
199
  {children}
125
200
  </td>
@@ -136,6 +211,8 @@ interface TFProps {
136
211
  type?: TableType;
137
212
  align?: TableAlignVariant;
138
213
  width?: number | string;
214
+ sticky?: boolean;
215
+ stickyLeft?: number | string;
139
216
  }
140
217
 
141
218
  function TF({
@@ -144,6 +221,8 @@ function TF({
144
221
  type = 'standard',
145
222
  align = 'left',
146
223
  width,
224
+ sticky,
225
+ stickyLeft,
147
226
  }: TFProps) {
148
227
  tableStyles.useVariants({
149
228
  size,
@@ -153,10 +232,17 @@ function TF({
153
232
 
154
233
  const footerCellProps = getWebProps([(tableStyles.footerCell as any)({})]);
155
234
 
235
+ const stickyStyle: React.CSSProperties | undefined = sticky ? {
236
+ position: 'sticky',
237
+ left: stickyLeft ?? 0,
238
+ zIndex: 1,
239
+ backgroundColor: 'inherit',
240
+ } : undefined;
241
+
156
242
  return (
157
243
  <td
158
244
  {...footerCellProps}
159
- style={{ width }}
245
+ style={{ width, ...stickyStyle }}
160
246
  >
161
247
  {children}
162
248
  </td>
@@ -176,8 +262,10 @@ function Table<T = any>({
176
262
  data,
177
263
  type = 'standard',
178
264
  size = 'md',
179
- stickyHeader: _stickyHeader = false,
265
+ stickyHeader = false,
180
266
  onRowPress,
267
+ onColumnResize,
268
+ emptyState,
181
269
  // Spacing variants from ContainerStyleProps
182
270
  gap,
183
271
  padding,
@@ -241,10 +329,28 @@ function Table<T = any>({
241
329
  return column.footer;
242
330
  };
243
331
 
332
+ // Compute cumulative left offsets for sticky columns
333
+ const stickyLeftMap = useMemo(() => {
334
+ const map = new Map<string, number | string>();
335
+ let cumulativeLeft = 0;
336
+ for (const col of columns) {
337
+ if (!col.sticky) continue;
338
+ map.set(col.key, cumulativeLeft);
339
+ if (typeof col.width === 'number') {
340
+ cumulativeLeft += col.width;
341
+ } else if (typeof col.width === 'string') {
342
+ // For string widths (e.g. '200px'), use as-is for the first, then can't accumulate
343
+ // Only numeric widths support proper multi-column stacking
344
+ map.set(col.key, cumulativeLeft > 0 ? cumulativeLeft : 0);
345
+ }
346
+ }
347
+ return map;
348
+ }, [columns]);
349
+
244
350
  return (
245
351
  <div {...containerProps} {...ariaProps} id={id} data-testid={testID}>
246
352
  <table {...tableProps} role="table">
247
- <thead {...getWebProps([(tableStyles.thead as any)({})])}>
353
+ <thead {...getWebProps([(tableStyles.thead as any)({ sticky: stickyHeader })])}>
248
354
  <tr>
249
355
  {columns.map((column) => (
250
356
  <TH
@@ -253,6 +359,11 @@ function Table<T = any>({
253
359
  type={type}
254
360
  align={column.align}
255
361
  width={column.width}
362
+ sticky={column.sticky}
363
+ stickyLeft={stickyLeftMap.get(column.key)}
364
+ resizable={column.resizable}
365
+ minWidth={column.minWidth}
366
+ onResize={onColumnResize ? (w) => onColumnResize(column.key, w) : undefined}
256
367
  accessibilitySort={column.accessibilitySort}
257
368
  >
258
369
  {column.title}
@@ -261,28 +372,38 @@ function Table<T = any>({
261
372
  </tr>
262
373
  </thead>
263
374
  <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
- ))}
375
+ {data.length === 0 && emptyState ? (
376
+ <tr>
377
+ <td colSpan={columns.length} style={{ textAlign: 'center' }}>
378
+ {emptyState}
379
+ </td>
380
+ </tr>
381
+ ) : (
382
+ data.map((row, rowIndex) => (
383
+ <TR
384
+ key={rowIndex}
385
+ size={size}
386
+ type={type}
387
+ clickable={isClickable}
388
+ onClick={() => onRowPress?.(row, rowIndex)}
389
+ testID={testID ? `${testID}-row-${rowIndex}` : undefined}
390
+ >
391
+ {columns.map((column) => (
392
+ <TD
393
+ key={column.key}
394
+ size={size}
395
+ type={type}
396
+ align={column.align}
397
+ width={column.width}
398
+ sticky={column.sticky}
399
+ stickyLeft={stickyLeftMap.get(column.key)}
400
+ >
401
+ {getCellValue(column, row, rowIndex)}
402
+ </TD>
403
+ ))}
404
+ </TR>
405
+ ))
406
+ )}
286
407
  </tbody>
287
408
  {hasFooter && (
288
409
  <tfoot {...getWebProps([(tableStyles.tfoot as any)({})])}>
@@ -294,6 +415,8 @@ function Table<T = any>({
294
415
  type={type}
295
416
  align={column.align}
296
417
  width={column.width}
418
+ sticky={column.sticky}
419
+ stickyLeft={stickyLeftMap.get(column.key)}
297
420
  >
298
421
  {getFooterContent(column) ?? null}
299
422
  </TF>
@@ -17,6 +17,20 @@ 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
+ * Web only — uses CSS `position: sticky`.
23
+ */
24
+ sticky?: boolean;
25
+ /**
26
+ * Allows the column to be resized by dragging the right edge of the header.
27
+ * Web only.
28
+ */
29
+ resizable?: boolean;
30
+ /**
31
+ * Minimum width when resizing (default: 50).
32
+ */
33
+ minWidth?: number;
20
34
  }
21
35
 
22
36
  /**
@@ -33,6 +47,16 @@ export interface TableProps<T = any> extends ContainerStyleProps, AccessibilityP
33
47
  size?: TableSizeVariant;
34
48
  stickyHeader?: boolean;
35
49
  onRowPress?: (row: T, index: number) => void;
50
+ /**
51
+ * Called when a column is resized via drag handle.
52
+ * Receives the column key and the new width in pixels.
53
+ */
54
+ onColumnResize?: (key: string, width: number) => void;
55
+ /**
56
+ * Content to display when `data` is empty.
57
+ * Renders in place of the table body.
58
+ */
59
+ emptyState?: ReactNode;
36
60
  style?: StyleProp<ViewStyle>;
37
61
  testID?: string;
38
62
  }
@@ -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;