aix 0.6.1 → 0.6.2

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/README.md CHANGED
@@ -25,57 +25,49 @@ Wrap your `ScrollView` with `Aix`, and wrap your messages with `AixCell`.
25
25
  </details>
26
26
 
27
27
  ```tsx
28
- import { Aix, AixCell } from 'aix';
29
- import { Message } from 'path/to/your/message';
30
- import { Composer } from 'path/to/your/composer';
28
+ import { Aix, AixCell } from 'aix'
29
+ import { Message } from 'path/to/your/message'
30
+ import { Composer } from 'path/to/your/composer'
31
31
 
32
32
  export function ChatScreen({ messages }) {
33
33
  return (
34
34
  <Aix style={{ flex: 1 }}>
35
35
  <ScrollView>
36
36
  {messages.map((message) => (
37
- <AixCell
38
- key={message.id}
39
- index={index}
40
- isLast={index === messages.length - 1}
41
- >
37
+ <AixCell key={message.id} index={index} isLast={index === messages.length - 1}>
42
38
  <Message message={message} />
43
39
  </AixCell>
44
40
  ))}
45
41
  </ScrollView>
46
42
  </Aix>
47
- );
43
+ )
48
44
  }
49
45
  ```
50
46
 
51
47
  To add a floating composer which lets content scroll under it, you can use the `AixFooter` and `KeyboardStickyView` from `react-native-keyboard-controller`:
52
48
 
53
49
  ```tsx
54
- import { Aix, AixCell, AixFooter } from 'aix';
55
- import { KeyboardStickyView } from 'react-native-keyboard-controller';
50
+ import { Aix, AixCell, AixFooter } from 'aix'
51
+ import { KeyboardStickyView } from 'react-native-keyboard-controller'
56
52
 
57
53
  export function ChatScreen({ messages }) {
58
54
  return (
59
55
  <Aix style={{ flex: 1 }}>
60
56
  <ScrollView>
61
57
  {messages.map((message) => (
62
- <AixCell
63
- key={message.id}
64
- index={index}
65
- isLast={index === messages.length - 1}
66
- >
58
+ <AixCell key={message.id} index={index} isLast={index === messages.length - 1}>
67
59
  <Message message={message} />
68
60
  </AixCell>
69
61
  ))}
70
62
  </ScrollView>
71
63
 
72
64
  <KeyboardStickyView offset={{ opened: 0, closed: -bottomInsetPadding }}>
73
- <AixFooter style={{ position: 'absolute', inset: 0, top: 'auto'}}>
65
+ <AixFooter fixInput style={{ position: 'absolute', inset: 0, top: 'auto' }}>
74
66
  <Composer />
75
67
  </AixFooter>
76
68
  </KeyboardStickyView>
77
69
  </Aix>
78
- );
70
+ )
79
71
  }
80
72
  ```
81
73
 
@@ -93,34 +85,34 @@ The main container component that provides keyboard-aware behavior and manages s
93
85
 
94
86
  #### Props
95
87
 
96
- | Prop | Type | Default | Description |
97
- |------|------|---------|-------------|
98
- | `shouldStartAtEnd` | `boolean` | - | Whether the scroll view should start scrolled to the end of the content. |
99
- | `scrollOnFooterSizeUpdate` | `object` | `{ enabled: true, scrolledToEndThreshold: 100, animated: false }` | Control the behavior of scrolling when the footer size changes. By default, changing the height of the footer will shift content up in the scroll view. |
100
- | `scrollEndReachedThreshold` | `number` | `max(blankSize, 200)` | The number of pixels from the bottom of the scroll view to the end of the content that is considered "near the end". Used by `onScrolledNearEndChange` and to determine if content should shift up when keyboard opens. |
101
- | `onScrolledNearEndChange` | `(isNearEnd: boolean) => void` | - | Callback fired when the scroll position transitions between "near end" and "not near end" states. Reactive to user scrolling, content size changes, parent size changes, and keyboard height changes. Uses `scrollEndReachedThreshold` to determine the threshold. |
102
- | `additionalContentInsets` | `object` | - | Additional content insets applied when keyboard is open or closed. Shape: `{ top?: { whenKeyboardOpen, whenKeyboardClosed }, bottom?: { whenKeyboardOpen, whenKeyboardClosed } }` |
103
- | `additionalScrollIndicatorInsets` | `object` | - | Additional insets for the scroll indicator, added to existing safe area insets. Applied to `verticalScrollIndicatorInsets` on iOS. |
104
- | `mainScrollViewID` | `string` | - | The `nativeID` of the scroll view to use. If provided, will search for a scroll view with this `accessibilityIdentifier`. |
105
- | `penultimateCellIndex` | `number` | - | The index of the second-to-last message (typically the last user message in AI chat apps). Used to determine which message will be scrolled into view. Useful when you have custom message types like timestamps in your list. |
88
+ | Prop | Type | Default | Description |
89
+ | --------------------------------- | ------------------------------ | ----------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
90
+ | `shouldStartAtEnd` | `boolean` | - | Whether the scroll view should start scrolled to the end of the content. |
91
+ | `scrollOnFooterSizeUpdate` | `object` | `{ enabled: true, scrolledToEndThreshold: 100, animated: false }` | Control the behavior of scrolling when the footer size changes. By default, changing the height of the footer will shift content up in the scroll view. |
92
+ | `scrollEndReachedThreshold` | `number` | `max(blankSize, 200)` | The number of pixels from the bottom of the scroll view to the end of the content that is considered "near the end". Used by `onScrolledNearEndChange` and to determine if content should shift up when keyboard opens. |
93
+ | `onScrolledNearEndChange` | `(isNearEnd: boolean) => void` | - | Callback fired when the scroll position transitions between "near end" and "not near end" states. Reactive to user scrolling, content size changes, parent size changes, and keyboard height changes. Uses `scrollEndReachedThreshold` to determine the threshold. |
94
+ | `additionalContentInsets` | `object` | - | Additional content insets applied when keyboard is open or closed. Shape: `{ top?: { whenKeyboardOpen, whenKeyboardClosed }, bottom?: { whenKeyboardOpen, whenKeyboardClosed } }` |
95
+ | `additionalScrollIndicatorInsets` | `object` | - | Additional insets for the scroll indicator, added to existing safe area insets. Applied to `verticalScrollIndicatorInsets` on iOS. |
96
+ | `mainScrollViewID` | `string` | - | The `nativeID` of the scroll view to use. If provided, will search for a scroll view with this `accessibilityIdentifier`. |
97
+ | `penultimateCellIndex` | `number` | - | The index of the second-to-last message (typically the last user message in AI chat apps). Used to determine which message will be scrolled into view. Useful when you have custom message types like timestamps in your list. |
106
98
 
107
99
  #### Ref Methods
108
100
 
109
101
  Access these methods via `useAixRef()`:
110
102
 
111
103
  ```tsx
112
- const aix = useAixRef();
104
+ const aix = useAixRef()
113
105
 
114
106
  // Scroll to the end of the content
115
- aix.current?.scrollToEnd(animated);
107
+ aix.current?.scrollToEnd(animated)
116
108
 
117
109
  // Scroll to a specific index when the blank size is ready
118
- aix.current?.scrollToIndexWhenBlankSizeReady(index, animated, waitForKeyboardToEnd);
110
+ aix.current?.scrollToIndexWhenBlankSizeReady(index, animated, waitForKeyboardToEnd)
119
111
  ```
120
112
 
121
- | Method | Parameters | Description |
122
- |--------|------------|-------------|
123
- | `scrollToEnd` | `animated?: boolean` | Scrolls to the end of the content. |
113
+ | Method | Parameters | Description |
114
+ | --------------------------------- | ------------------------------------------------------------------- | -------------------------------------------------------------------------- |
115
+ | `scrollToEnd` | `animated?: boolean` | Scrolls to the end of the content. |
124
116
  | `scrollToIndexWhenBlankSizeReady` | `index: number, animated?: boolean, waitForKeyboardToEnd?: boolean` | Scrolls to a specific cell index once the blank size calculation is ready. |
125
117
 
126
118
  ---
@@ -131,10 +123,10 @@ A wrapper component for each message in the list. It communicates cell position
131
123
 
132
124
  #### Props
133
125
 
134
- | Prop | Type | Required | Description |
135
- |------|------|----------|-------------|
136
- | `index` | `number` | Yes | The index of this cell in the message list. |
137
- | `isLast` | `boolean` | Yes | Whether this cell is the last item in the list. Used for scroll positioning and animations. |
126
+ | Prop | Type | Required | Description |
127
+ | -------- | --------- | -------- | ------------------------------------------------------------------------------------------- |
128
+ | `index` | `number` | Yes | The index of this cell in the message list. |
129
+ | `isLast` | `boolean` | Yes | Whether this cell is the last item in the list. Used for scroll positioning and animations. |
138
130
 
139
131
  ---
140
132
 
@@ -142,21 +134,175 @@ A wrapper component for each message in the list. It communicates cell position
142
134
 
143
135
  A footer component for floating composers that allows content to scroll underneath it. The footer's height is automatically tracked for proper scroll offset calculations.
144
136
 
145
- #### Props
146
-
147
- Accepts all standard React Native `View` props.
148
-
149
137
  #### Important Notes
150
138
 
151
139
  - **Do not apply vertical padding** (`padding`, `paddingBottom`) directly to `AixFooter`. Apply padding to a child view instead.
152
- - Position the footer absolutely at the bottom of the `Aix` container:
140
+ - Position the footer absolutely at the bottom of the `Aix` container.
141
+
142
+ #### Recipe
153
143
 
154
144
  ```tsx
155
- <AixFooter style={{ position: 'absolute', inset: 0, top: 'auto' }}>
145
+ <AixFooter
146
+ fixInput
147
+ stickToKeyboard={{
148
+ enabled: true,
149
+ offset: {
150
+ whenKeyboardOpen: 0,
151
+ whenKeyboardClosed: bottomSafeAreaInset,
152
+ },
153
+ }}
154
+ style={{ position: 'absolute', inset: 0, top: 'auto' }}
155
+ >
156
156
  <Composer />
157
157
  </AixFooter>
158
158
  ```
159
159
 
160
+ #### Props
161
+
162
+ | Prop | Type | Default | Description |
163
+ | ----------------- | -------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
164
+ | `fixInput` | `boolean` | - | Patches the `TextInput` inside the footer to disable scroll bouncing, hide scroll indicators, enable interactive keyboard dismiss, and add swipe-up-to-focus. Recommended for multiline inputs. This may move to `AixInputWrapper` in the future. |
165
+ | `stickToKeyboard` | `AixStickToKeyboard` | - | Controls whether the footer translates upward with the keyboard. See shape below. |
166
+
167
+ Also accepts all standard React Native `View` props.
168
+
169
+ **`AixStickToKeyboard`**
170
+
171
+ | Field | Type | Required | Description |
172
+ | --------------------------- | --------- | -------- | --------------------------------------------------------------------------------------------- |
173
+ | `enabled` | `boolean` | Yes | Whether the footer should translate upward with the keyboard. |
174
+ | `offset.whenKeyboardOpen` | `number` | No | Additional vertical offset applied when the keyboard is fully open. |
175
+ | `offset.whenKeyboardClosed` | `number` | No | Additional vertical offset applied when the keyboard is closed (e.g. bottom safe area inset). |
176
+
177
+ ---
178
+
179
+ ### `AixDropzone`
180
+
181
+ A wrapper component that enables drag-and-drop file support for your chat interface. Users can drop images, documents, and other files onto the zone.
182
+
183
+ On iOS, this uses a native drop interaction handler. On other platforms, it renders its children without drop support.
184
+
185
+ #### Recipe
186
+
187
+ Wrap your chat screen with `AixDropzone` to handle dropped files:
188
+
189
+ ```tsx
190
+ import { useState } from 'react'
191
+ import { AixDropzone, Aix, type AixInputWrapperOnPasteEvent } from 'aix'
192
+
193
+ function ChatScreen() {
194
+ const [attachments, setAttachments] = useState<AixInputWrapperOnPasteEvent[]>([])
195
+
196
+ return (
197
+ <AixDropzone
198
+ onDrop={(events) => {
199
+ setAttachments((prev) => [...prev, ...events])
200
+ }}
201
+ >
202
+ <Aix shouldStartAtEnd style={{ flex: 1 }}>
203
+ {/* ScrollView, messages, footer... */}
204
+ </Aix>
205
+ </AixDropzone>
206
+ )
207
+ }
208
+ ```
209
+
210
+ #### Props
211
+
212
+ | Prop | Type | Default | Description |
213
+ | ---------- | ------------------------------------------------- | ------- | -------------------------------------------------------------------------------------- |
214
+ | `onDrop` | `(events: AixInputWrapperOnPasteEvent[]) => void` | - | Called when the user drops files onto the zone. Each event describes one dropped item. |
215
+ | `children` | `ReactNode` | - | Content to render inside the drop zone. |
216
+
217
+ **`AixInputWrapperOnPasteEvent`**
218
+
219
+ | Field | Type | Description |
220
+ | --------------- | --------- | ------------------------------------------------- |
221
+ | `type` | `string` | The kind of item (e.g. `"image"`). |
222
+ | `filePath` | `string` | Local file path to the dropped or pasted content. |
223
+ | `fileExtension` | `string?` | File extension (e.g. `"png"`, `"pdf"`). |
224
+ | `fileName` | `string?` | Original file name. |
225
+
226
+ ---
227
+
228
+ ### `AixInputWrapper`
229
+
230
+ A wrapper for your `TextInput` that intercepts paste events, letting users paste images and files from their clipboard into the composer.
231
+
232
+ On iOS, this uses a native view that overrides paste behavior to capture rich content. On other platforms, it renders its children without paste interception.
233
+
234
+ #### Recipe
235
+
236
+ Wrap your `TextInput` with `AixInputWrapper` to handle pasted images and files:
237
+
238
+ ```tsx
239
+ import { useState } from 'react'
240
+ import { TextInput } from 'react-native'
241
+ import { AixInputWrapper, type AixInputWrapperOnPasteEvent } from 'aix'
242
+
243
+ function Composer() {
244
+ const [attachments, setAttachments] = useState<AixInputWrapperOnPasteEvent[]>([])
245
+
246
+ return (
247
+ <AixInputWrapper
248
+ editMenuDefaultActions={['paste']}
249
+ onPaste={(events) => {
250
+ setAttachments((prev) => [...prev, ...events])
251
+ }}
252
+ >
253
+ <TextInput placeholder='Type a message...' multiline />
254
+ </AixInputWrapper>
255
+ )
256
+ }
257
+ ```
258
+
259
+ Each event in `onPaste` has the same [`AixInputWrapperOnPasteEvent`](#aixinputwrapperonpasteevent) shape described above.
260
+
261
+ #### Props
262
+
263
+ | Prop | Type | Default | Description |
264
+ | ------------------------ | ------------------------------------------------- | ------- | -------------------------------------------------------------------------------- |
265
+ | `onPaste` | `(events: AixInputWrapperOnPasteEvent[]) => void` | - | Called when the user pastes rich content (images, files) into the wrapped input. |
266
+ | `pasteConfiguration` | `string[]` | - | UTI types to accept for paste events. |
267
+ | `editMenuDefaultActions` | `string[]` | - | Which default edit menu actions to show (e.g. `['paste']`). |
268
+ | `maxLines` | `number` | - | Maximum number of visible lines for the wrapped input. |
269
+ | `maxChars` | `number` | - | Maximum character count for the wrapped input. |
270
+ | `children` | `ReactNode` | - | Should contain your `TextInput`. |
271
+
272
+ Also accepts all standard React Native `View` props.
273
+
274
+ ---
275
+
276
+ ### `TextFadeInStaggeredIfStreaming`
277
+
278
+ Animates text children with a staggered word-by-word fade-in effect. Each word fades in over 500ms, staggered 32ms apart. A pool system limits concurrent animations for smooth performance.
279
+
280
+ The `disabled` state is captured on mount. If the component mounts with `disabled={true}`, animation stays off even if `disabled` later becomes `false`. This prevents completed messages from re-animating on re-render.
281
+
282
+ #### Recipe
283
+
284
+ ```tsx
285
+ import { Text } from 'react-native'
286
+ import { TextFadeInStaggeredIfStreaming } from 'aix'
287
+
288
+ function AssistantMessage({ content, isStreaming }) {
289
+ return (
290
+ <Text>
291
+ <TextFadeInStaggeredIfStreaming disabled={!isStreaming}>
292
+ {content}
293
+ </TextFadeInStaggeredIfStreaming>
294
+ </Text>
295
+ )
296
+ }
297
+ ```
298
+
299
+ #### Props
300
+
301
+ | Prop | Type | Required | Description |
302
+ | ---------- | ----------- | -------- | ----------------------------------------------------------------------------------------------------------------------- |
303
+ | `disabled` | `boolean` | Yes | Whether to disable the fade-in animation. Pass `true` for completed messages, `false` for currently-streaming messages. |
304
+ | `children` | `ReactNode` | - | Text content to animate. String children are split by word and each word fades in individually. |
305
+
160
306
  ---
161
307
 
162
308
  ### `useAixRef`
@@ -164,57 +310,110 @@ Accepts all standard React Native `View` props.
164
310
  A hook that returns a ref to access imperative methods on the `Aix` component.
165
311
 
166
312
  ```tsx
167
- import { useAixRef } from 'aix';
313
+ import { useAixRef } from 'aix'
168
314
 
169
315
  function Chat({ messages }) {
170
- const aix = useAixRef();
316
+ const aix = useAixRef()
171
317
  const send = useSendMessage()
172
-
318
+
173
319
  const handleSend = () => {
174
320
  // Scroll to end after sending a message
175
- send(message);
176
- aix.current?.scrollToIndexWhenBlankSizeReady(messages.length + 1, true);
177
- requestAnimationFrame(Keyboard.dismiss);
178
- };
179
-
180
- return <Aix ref={aix}>{/* ... */}</Aix>;
321
+ send(message)
322
+ aix.current?.scrollToIndexWhenBlankSizeReady(messages.length + 1, true)
323
+ requestAnimationFrame(Keyboard.dismiss)
324
+ }
325
+
326
+ return <Aix ref={aix}>{/* ... */}</Aix>
181
327
  }
182
328
  ```
183
329
 
184
330
  ---
185
331
 
186
- ### Scroll to End Button
332
+ ### `useContentInsetHandler`
187
333
 
188
- You can use `onScrolledNearEndChange` to show a "scroll to end" button when the user scrolls away from the bottom:
334
+ A hook for receiving content inset updates from the native scroll view. Use this when you want to apply content insets yourself (e.g. via Reanimated shared values) instead of letting `Aix` apply them natively.
335
+
336
+ #### Recipe
337
+
338
+ Apply content insets with Reanimated for custom control:
189
339
 
190
340
  ```tsx
191
- import { Aix, useAixRef } from 'aix';
192
- import { useState } from 'react';
341
+ import { Aix, useContentInsetHandler } from 'aix'
342
+ import { useSharedValue, useAnimatedProps } from 'react-native-reanimated'
343
+ import Animated from 'react-native-reanimated'
193
344
 
194
345
  function Chat() {
195
- const aix = useAixRef();
196
- const [isNearEnd, setIsNearEnd] = useState(false);
346
+ const bottomInset = useSharedValue<number | null>(null)
347
+
348
+ const contentInsetHandler = useContentInsetHandler((insets) => {
349
+ 'worklet'
350
+ bottomInset.set(insets.bottom ?? null)
351
+ })
352
+
353
+ const animatedScrollViewProps = useAnimatedProps(() => ({
354
+ contentInset: {
355
+ top: 0,
356
+ bottom: bottomInset.get() ?? 0,
357
+ left: 0,
358
+ right: 0,
359
+ },
360
+ }))
197
361
 
198
362
  return (
199
363
  <Aix
200
- ref={aix}
201
- scrollEndReachedThreshold={200}
202
- onScrolledNearEndChange={setIsNearEnd}
364
+ shouldStartAtEnd
365
+ shouldApplyContentInsets={false}
366
+ onWillApplyContentInsets={contentInsetHandler}
203
367
  >
204
- {/* ScrollView and messages... */}
205
-
206
- {!isNearEnd && (
207
- <Button onPress={() => aix.current?.scrollToEnd(true)} />
208
- )}
368
+ <Animated.ScrollView animatedProps={animatedScrollViewProps}>
369
+ {/* messages... */}
370
+ </Animated.ScrollView>
209
371
  </Aix>
210
- );
372
+ )
211
373
  }
212
374
  ```
213
375
 
214
- ## TODOs
376
+ #### Parameters
377
+
378
+ | Parameter | Type | Description |
379
+ | -------------- | ------------------------------------ | ------------------------------------------------------------------------ |
380
+ | `handler` | `(insets: AixContentInsets) => void` | Callback receiving computed content insets. Can be a Reanimated worklet. |
381
+ | `dependencies` | `unknown[]` | Dependency array for the callback (default: `[]`). |
382
+
383
+ **`AixContentInsets`**
215
384
 
385
+ | Field | Type | Description |
386
+ | -------- | --------- | --------------------- |
387
+ | `top` | `number?` | Top content inset. |
388
+ | `left` | `number?` | Left content inset. |
389
+ | `bottom` | `number?` | Bottom content inset. |
390
+ | `right` | `number?` | Right content inset. |
391
+
392
+ ---
393
+
394
+ ### Scroll to End Button
395
+
396
+ You can use `onScrolledNearEndChange` to show a "scroll to end" button when the user scrolls away from the bottom:
397
+
398
+ ```tsx
399
+ import { Aix, useAixRef } from 'aix'
400
+ import { useState } from 'react'
401
+
402
+ function Chat() {
403
+ const aix = useAixRef()
404
+ const [isNearEnd, setIsNearEnd] = useState(false)
405
+
406
+ return (
407
+ <Aix ref={aix} scrollEndReachedThreshold={200} onScrolledNearEndChange={setIsNearEnd}>
408
+ {/* ScrollView and messages... */}
409
+
410
+ {!isNearEnd && <Button onPress={() => aix.current?.scrollToEnd(true)} />}
411
+ </Aix>
412
+ )
413
+ }
414
+ ```
216
415
 
217
416
  ## Requirements
218
417
 
219
418
  - React Native v0.78.0 or higher
220
- - Node 18.0.0 or higher
419
+ - Node 18.0.0 or higher
@@ -154,6 +154,7 @@ class HybridAixCellView: HybridAixCellViewSpec {
154
154
  private func updateRegistration() {
155
155
  guard let ctx = getAixContext() else { return }
156
156
  ctx.registerCell(self)
157
+ updateBlankViewStatus()
157
158
  }
158
159
 
159
160
  /// Update blank view status (called when isLast changes)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aix",
3
- "version": "0.6.1",
3
+ "version": "0.6.2",
4
4
  "author": "Fernando Rojo",
5
5
  "repository": {
6
6
  "type": "git",