panelui-native 0.6.0 → 0.7.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 (133) hide show
  1. package/README.md +1 -1
  2. package/lib/module/components/accordion/index.js +7 -5
  3. package/lib/module/components/accordion/index.js.map +1 -1
  4. package/lib/module/components/alert/index.js +3 -6
  5. package/lib/module/components/alert/index.js.map +1 -1
  6. package/lib/module/components/bottom-sheet/index.js +67 -3
  7. package/lib/module/components/bottom-sheet/index.js.map +1 -1
  8. package/lib/module/components/button/index.js +36 -1
  9. package/lib/module/components/button/index.js.map +1 -1
  10. package/lib/module/components/empty-state/index.js +4 -5
  11. package/lib/module/components/empty-state/index.js.map +1 -1
  12. package/lib/module/components/inline-select/index.js +171 -46
  13. package/lib/module/components/inline-select/index.js.map +1 -1
  14. package/lib/module/components/input/index.js +28 -5
  15. package/lib/module/components/input/index.js.map +1 -1
  16. package/lib/module/components/input-group/index.js +0 -1
  17. package/lib/module/components/input-group/index.js.map +1 -1
  18. package/lib/module/components/item/index.js +359 -0
  19. package/lib/module/components/item/index.js.map +1 -0
  20. package/lib/module/components/label/index.js +5 -3
  21. package/lib/module/components/label/index.js.map +1 -1
  22. package/lib/module/components/message/index.js +318 -0
  23. package/lib/module/components/message/index.js.map +1 -0
  24. package/lib/module/components/scroll-fade/index.js +119 -71
  25. package/lib/module/components/scroll-fade/index.js.map +1 -1
  26. package/lib/module/components/select/index.js +35 -6
  27. package/lib/module/components/select/index.js.map +1 -1
  28. package/lib/module/components/shimmer/index.js +111 -64
  29. package/lib/module/components/shimmer/index.js.map +1 -1
  30. package/lib/module/components/surface/index.js +3 -2
  31. package/lib/module/components/surface/index.js.map +1 -1
  32. package/lib/module/components/switch/index.js +20 -1
  33. package/lib/module/components/switch/index.js.map +1 -1
  34. package/lib/module/components/toast/index.js +7 -8
  35. package/lib/module/components/toast/index.js.map +1 -1
  36. package/lib/module/components/typography/index.js +3 -3
  37. package/lib/module/hooks/index.js +1 -0
  38. package/lib/module/hooks/index.js.map +1 -1
  39. package/lib/module/hooks/use-breakpoint.js +2 -2
  40. package/lib/module/hooks/use-copy-to-clipboard.js +0 -2
  41. package/lib/module/hooks/use-copy-to-clipboard.js.map +1 -1
  42. package/lib/module/hooks/use-keyboard-avoidance.js +120 -0
  43. package/lib/module/hooks/use-keyboard-avoidance.js.map +1 -0
  44. package/lib/module/icons/index.js +4 -4
  45. package/lib/module/index.js +6 -0
  46. package/lib/module/index.js.map +1 -1
  47. package/lib/module/native/index.js +53 -0
  48. package/lib/module/native/index.js.map +1 -0
  49. package/lib/module/primitives/keyboard-avoider.js +40 -0
  50. package/lib/module/primitives/keyboard-avoider.js.map +1 -0
  51. package/lib/module/providers/panel-ui-provider.js +24 -4
  52. package/lib/module/providers/panel-ui-provider.js.map +1 -1
  53. package/lib/module/theme/use-theme.js +1 -1
  54. package/lib/typescript/src/components/accordion/index.d.ts +7 -5
  55. package/lib/typescript/src/components/accordion/index.d.ts.map +1 -1
  56. package/lib/typescript/src/components/alert/index.d.ts +3 -6
  57. package/lib/typescript/src/components/alert/index.d.ts.map +1 -1
  58. package/lib/typescript/src/components/bottom-sheet/index.d.ts +21 -1
  59. package/lib/typescript/src/components/bottom-sheet/index.d.ts.map +1 -1
  60. package/lib/typescript/src/components/button/index.d.ts +11 -0
  61. package/lib/typescript/src/components/button/index.d.ts.map +1 -1
  62. package/lib/typescript/src/components/empty-state/index.d.ts +4 -5
  63. package/lib/typescript/src/components/empty-state/index.d.ts.map +1 -1
  64. package/lib/typescript/src/components/inline-select/index.d.ts +29 -6
  65. package/lib/typescript/src/components/inline-select/index.d.ts.map +1 -1
  66. package/lib/typescript/src/components/input/index.d.ts +13 -0
  67. package/lib/typescript/src/components/input/index.d.ts.map +1 -1
  68. package/lib/typescript/src/components/input-group/index.d.ts +0 -1
  69. package/lib/typescript/src/components/input-group/index.d.ts.map +1 -1
  70. package/lib/typescript/src/components/item/index.d.ts +286 -0
  71. package/lib/typescript/src/components/item/index.d.ts.map +1 -0
  72. package/lib/typescript/src/components/label/index.d.ts +5 -3
  73. package/lib/typescript/src/components/label/index.d.ts.map +1 -1
  74. package/lib/typescript/src/components/message/index.d.ts +170 -0
  75. package/lib/typescript/src/components/message/index.d.ts.map +1 -0
  76. package/lib/typescript/src/components/scroll-fade/index.d.ts +18 -9
  77. package/lib/typescript/src/components/scroll-fade/index.d.ts.map +1 -1
  78. package/lib/typescript/src/components/select/index.d.ts +16 -1
  79. package/lib/typescript/src/components/select/index.d.ts.map +1 -1
  80. package/lib/typescript/src/components/shimmer/index.d.ts +38 -20
  81. package/lib/typescript/src/components/shimmer/index.d.ts.map +1 -1
  82. package/lib/typescript/src/components/surface/index.d.ts.map +1 -1
  83. package/lib/typescript/src/components/switch/index.d.ts +10 -0
  84. package/lib/typescript/src/components/switch/index.d.ts.map +1 -1
  85. package/lib/typescript/src/components/toast/index.d.ts +4 -4
  86. package/lib/typescript/src/components/toast/index.d.ts.map +1 -1
  87. package/lib/typescript/src/hooks/index.d.ts +1 -0
  88. package/lib/typescript/src/hooks/index.d.ts.map +1 -1
  89. package/lib/typescript/src/hooks/use-breakpoint.d.ts +2 -2
  90. package/lib/typescript/src/hooks/use-copy-to-clipboard.d.ts +0 -2
  91. package/lib/typescript/src/hooks/use-copy-to-clipboard.d.ts.map +1 -1
  92. package/lib/typescript/src/hooks/use-keyboard-avoidance.d.ts +20 -0
  93. package/lib/typescript/src/hooks/use-keyboard-avoidance.d.ts.map +1 -0
  94. package/lib/typescript/src/icons/index.d.ts +4 -4
  95. package/lib/typescript/src/index.d.ts +4 -0
  96. package/lib/typescript/src/index.d.ts.map +1 -1
  97. package/lib/typescript/src/native/index.d.ts +35 -0
  98. package/lib/typescript/src/native/index.d.ts.map +1 -0
  99. package/lib/typescript/src/primitives/keyboard-avoider.d.ts +22 -0
  100. package/lib/typescript/src/primitives/keyboard-avoider.d.ts.map +1 -0
  101. package/lib/typescript/src/providers/panel-ui-provider.d.ts +1 -1
  102. package/lib/typescript/src/providers/panel-ui-provider.d.ts.map +1 -1
  103. package/lib/typescript/src/theme/use-theme.d.ts +1 -1
  104. package/package.json +12 -3
  105. package/src/components/accordion/index.tsx +7 -5
  106. package/src/components/alert/index.tsx +3 -6
  107. package/src/components/bottom-sheet/index.tsx +82 -2
  108. package/src/components/button/index.tsx +52 -0
  109. package/src/components/empty-state/index.tsx +4 -5
  110. package/src/components/inline-select/index.tsx +191 -44
  111. package/src/components/input/index.tsx +39 -3
  112. package/src/components/input-group/index.tsx +0 -1
  113. package/src/components/item/index.tsx +387 -0
  114. package/src/components/label/index.tsx +5 -3
  115. package/src/components/message/index.tsx +312 -0
  116. package/src/components/scroll-fade/index.tsx +163 -51
  117. package/src/components/select/index.tsx +54 -9
  118. package/src/components/shimmer/index.tsx +151 -58
  119. package/src/components/surface/index.tsx +3 -2
  120. package/src/components/switch/index.tsx +27 -1
  121. package/src/components/toast/index.tsx +7 -8
  122. package/src/components/typography/index.tsx +3 -3
  123. package/src/hooks/index.ts +5 -0
  124. package/src/hooks/use-breakpoint.ts +2 -2
  125. package/src/hooks/use-copy-to-clipboard.ts +0 -2
  126. package/src/hooks/use-keyboard-avoidance.ts +140 -0
  127. package/src/icons/index.tsx +4 -4
  128. package/src/index.ts +32 -0
  129. package/src/native/index.ts +79 -0
  130. package/src/primitives/keyboard-avoider.tsx +49 -0
  131. package/src/providers/panel-ui-provider.tsx +30 -9
  132. package/src/theme/use-theme.ts +1 -1
  133. package/theme.css +5 -3
@@ -0,0 +1,387 @@
1
+ /**
2
+ * Item — a row of content: media on one side, a title and description in the
3
+ * middle, actions on the other side.
4
+ *
5
+ * It is the shape almost every list in an app already has — a settings row, a
6
+ * file in a picker, a member in a team list, a tool call in a transcript — so
7
+ * it exists as one composable primitive rather than being rebuilt per screen
8
+ * with slightly different padding each time.
9
+ *
10
+ * ```tsx
11
+ * <Item variant="outline">
12
+ * <Item.Media variant="icon"><ReceiptIcon /></Item.Media>
13
+ * <Item.Content>
14
+ * <Item.Title>Invoice.pdf</Item.Title>
15
+ * <Item.Description>2.4 MB · Updated yesterday</Item.Description>
16
+ * </Item.Content>
17
+ * <Item.Actions><Button size="sm">Open</Button></Item.Actions>
18
+ * </Item>
19
+ * ```
20
+ *
21
+ * Two axes, and they are independent. `orientation` on the item decides
22
+ * whether its own parts sit side by side or stack into a card; `orientation`
23
+ * on `Item.Group` decides whether the items themselves run down the screen or
24
+ * across it. A carousel wants both set to the non-default: a horizontal group
25
+ * of vertical items.
26
+ */
27
+ import { createContext, forwardRef, useContext, type ReactNode } from 'react';
28
+ import { View, type ViewProps } from 'react-native';
29
+ import { tv, type VariantProps } from 'tailwind-variants';
30
+ import { AnimatedPressable, type AnimatedPressableProps } from '../../primitives/animated-pressable';
31
+ import { Text, type TextProps } from '../../primitives/text';
32
+ import { cn } from '../../utils/cn';
33
+
34
+ type ItemSize = 'default' | 'sm' | 'xs';
35
+ type ItemOrientation = 'horizontal' | 'vertical';
36
+
37
+ const itemVariants = tv({
38
+ slots: {
39
+ root: 'gap-3 rounded-xl',
40
+ title: 'font-medium text-foreground',
41
+ description: 'text-muted-foreground',
42
+ },
43
+ variants: {
44
+ variant: {
45
+ default: {},
46
+ outline: { root: 'border border-border' },
47
+ muted: { root: 'bg-muted' },
48
+ },
49
+ size: {
50
+ default: { root: 'p-4', title: 'text-base', description: 'text-sm' },
51
+ sm: { root: 'p-3', title: 'text-sm', description: 'text-xs' },
52
+ xs: { root: 'gap-2 p-2', title: 'text-sm', description: 'text-xs' },
53
+ },
54
+ orientation: {
55
+ /** Media, text and actions side by side — the list-row shape. */
56
+ horizontal: { root: 'w-full flex-row items-center' },
57
+ /** Stacked into a card — the shape a horizontal carousel wants. */
58
+ vertical: { root: 'flex-col items-start' },
59
+ },
60
+ disabled: {
61
+ true: { root: 'opacity-[0.64]' },
62
+ },
63
+ },
64
+ defaultVariants: {
65
+ variant: 'default',
66
+ size: 'default',
67
+ orientation: 'horizontal',
68
+ },
69
+ });
70
+
71
+ /** Sub-components inherit the row's density and axis rather than repeating them. */
72
+ const ItemContext = createContext<{ size: ItemSize; orientation: ItemOrientation }>({
73
+ size: 'default',
74
+ orientation: 'horizontal',
75
+ });
76
+
77
+ const mediaVariants = tv({
78
+ base: 'shrink-0 items-center justify-center',
79
+ variants: {
80
+ variant: {
81
+ /** No box — for an Avatar or anything that styles itself. */
82
+ default: '',
83
+ /** Rounded square tile sized for an icon. */
84
+ icon: 'rounded-lg border border-border bg-muted',
85
+ /** Clipped frame for an image or thumbnail. */
86
+ image: 'overflow-hidden rounded-lg bg-muted',
87
+ },
88
+ size: {
89
+ default: '',
90
+ sm: '',
91
+ xs: '',
92
+ },
93
+ },
94
+ compoundVariants: [
95
+ { variant: 'icon', size: 'default', class: 'h-10 w-10' },
96
+ { variant: 'icon', size: 'sm', class: 'h-8 w-8' },
97
+ { variant: 'icon', size: 'xs', class: 'h-6 w-6' },
98
+ { variant: 'image', size: 'default', class: 'h-12 w-12' },
99
+ { variant: 'image', size: 'sm', class: 'h-10 w-10' },
100
+ { variant: 'image', size: 'xs', class: 'h-8 w-8' },
101
+ ],
102
+ defaultVariants: {
103
+ variant: 'default',
104
+ size: 'default',
105
+ },
106
+ });
107
+
108
+ export interface ItemProps
109
+ extends Omit<AnimatedPressableProps, 'children' | 'disabled'>,
110
+ Omit<VariantProps<typeof itemVariants>, 'disabled'> {
111
+ className?: string;
112
+ disabled?: boolean;
113
+ /**
114
+ * Row density. `Item.Media`, `Item.Title` and `Item.Description` follow it,
115
+ * so it only needs setting here.
116
+ */
117
+ size?: ItemSize;
118
+ /**
119
+ * `horizontal` is the list row: media, text and actions side by side.
120
+ * `vertical` stacks them into a card, which is what a horizontal carousel
121
+ * wants — and it is also what `Item.Header` and `Item.Footer` need, since
122
+ * both are full-width strips.
123
+ */
124
+ orientation?: ItemOrientation;
125
+ children?: ReactNode;
126
+ }
127
+
128
+ /**
129
+ * Renders as a pressable when given `onPress`, and as a plain view otherwise,
130
+ * so a static row does not announce itself as a button.
131
+ */
132
+ const ItemRoot = forwardRef<View, ItemProps>(
133
+ (
134
+ {
135
+ className,
136
+ variant,
137
+ size = 'default',
138
+ orientation = 'horizontal',
139
+ disabled,
140
+ children,
141
+ onPress,
142
+ ...props
143
+ },
144
+ ref
145
+ ) => {
146
+ const { root } = itemVariants({ variant, size, orientation, disabled: !!disabled });
147
+
148
+ const body = !onPress ? (
149
+ <View
150
+ ref={ref}
151
+ accessibilityState={{ disabled: !!disabled }}
152
+ className={root({ className })}
153
+ {...(props as ViewProps)}
154
+ >
155
+ {children}
156
+ </View>
157
+ ) : (
158
+ <AnimatedPressable
159
+ ref={ref}
160
+ accessibilityRole="button"
161
+ accessibilityState={{ disabled: !!disabled }}
162
+ disabled={disabled}
163
+ onPress={onPress}
164
+ className={root({ className })}
165
+ {...props}
166
+ >
167
+ {children}
168
+ </AnimatedPressable>
169
+ );
170
+
171
+ return (
172
+ <ItemContext.Provider value={{ size, orientation }}>{body}</ItemContext.Provider>
173
+ );
174
+ }
175
+ );
176
+ ItemRoot.displayName = 'Item';
177
+
178
+ export interface ItemGroupProps extends ViewProps {
179
+ className?: string;
180
+ /**
181
+ * `vertical` stacks the items — the settings-list shape. `horizontal` runs
182
+ * them across instead, for a carousel; pair it with a scrollable and
183
+ * `orientation="vertical"` on each item so every entry reads as a card.
184
+ */
185
+ orientation?: ItemOrientation;
186
+ children?: ReactNode;
187
+ }
188
+
189
+ /** Stack of items. Pair with `Item.Separator` between them. */
190
+ const ItemGroup = forwardRef<View, ItemGroupProps>(
191
+ ({ className, orientation = 'vertical', children, ...props }, ref) => (
192
+ <View
193
+ ref={ref}
194
+ accessibilityRole="list"
195
+ className={cn(
196
+ orientation === 'horizontal'
197
+ ? 'flex-row items-stretch gap-3'
198
+ : 'w-full',
199
+ className
200
+ )}
201
+ {...props}
202
+ >
203
+ {children}
204
+ </View>
205
+ )
206
+ );
207
+ ItemGroup.displayName = 'Item.Group';
208
+
209
+ export interface ItemSeparatorProps extends ViewProps {
210
+ className?: string;
211
+ /** Match the group's axis: a horizontal group needs vertical hairlines. */
212
+ orientation?: ItemOrientation;
213
+ }
214
+
215
+ /** Hairline between rows in a group. */
216
+ const ItemSeparator = forwardRef<View, ItemSeparatorProps>(
217
+ ({ className, orientation = 'vertical', ...props }, ref) => (
218
+ <View
219
+ ref={ref}
220
+ className={cn(
221
+ orientation === 'horizontal' ? 'h-full w-px' : 'h-px w-full',
222
+ 'bg-border',
223
+ className
224
+ )}
225
+ {...props}
226
+ />
227
+ )
228
+ );
229
+ ItemSeparator.displayName = 'Item.Separator';
230
+
231
+ export interface ItemMediaProps
232
+ extends ViewProps,
233
+ VariantProps<typeof mediaVariants> {
234
+ className?: string;
235
+ children?: ReactNode;
236
+ }
237
+
238
+ /** Leading slot: an icon tile, a thumbnail, or an avatar passed through. */
239
+ const ItemMedia = forwardRef<View, ItemMediaProps>(
240
+ ({ className, variant, size, children, ...props }, ref) => {
241
+ const item = useContext(ItemContext);
242
+
243
+ return (
244
+ <View
245
+ ref={ref}
246
+ className={mediaVariants({ variant, size: size ?? item.size, className })}
247
+ {...props}
248
+ >
249
+ {children}
250
+ </View>
251
+ );
252
+ }
253
+ );
254
+ ItemMedia.displayName = 'Item.Media';
255
+
256
+ export interface ItemContentProps extends ViewProps {
257
+ className?: string;
258
+ children?: ReactNode;
259
+ }
260
+
261
+ /**
262
+ * The text column. In a horizontal item it takes the remaining width so the
263
+ * actions stay pinned to the trailing edge; in a vertical one it just fills
264
+ * the width, because there is nothing beside it to push against.
265
+ */
266
+ const ItemContent = forwardRef<View, ItemContentProps>(
267
+ ({ className, children, ...props }, ref) => {
268
+ const { orientation } = useContext(ItemContext);
269
+
270
+ return (
271
+ <View
272
+ ref={ref}
273
+ className={cn(
274
+ orientation === 'horizontal' ? 'flex-1' : 'w-full',
275
+ 'gap-0.5',
276
+ className
277
+ )}
278
+ {...props}
279
+ >
280
+ {children}
281
+ </View>
282
+ );
283
+ }
284
+ );
285
+ ItemContent.displayName = 'Item.Content';
286
+
287
+ export interface ItemTitleProps extends TextProps {
288
+ className?: string;
289
+ }
290
+
291
+ const ItemTitle = forwardRef<React.ElementRef<typeof Text>, ItemTitleProps>(
292
+ ({ className, ...props }, ref) => {
293
+ const { size } = useContext(ItemContext);
294
+ const { title } = itemVariants({ size });
295
+
296
+ return <Text ref={ref} className={title({ className })} {...props} />;
297
+ }
298
+ );
299
+ ItemTitle.displayName = 'Item.Title';
300
+
301
+ export interface ItemDescriptionProps extends TextProps {
302
+ className?: string;
303
+ }
304
+
305
+ const ItemDescription = forwardRef<
306
+ React.ElementRef<typeof Text>,
307
+ ItemDescriptionProps
308
+ >(({ className, ...props }, ref) => {
309
+ const { size } = useContext(ItemContext);
310
+ const { description } = itemVariants({ size });
311
+
312
+ return <Text ref={ref} className={description({ className })} {...props} />;
313
+ });
314
+ ItemDescription.displayName = 'Item.Description';
315
+
316
+ export interface ItemActionsProps extends ViewProps {
317
+ className?: string;
318
+ children?: ReactNode;
319
+ }
320
+
321
+ /** Trailing slot: buttons, a chevron, a switch. */
322
+ const ItemActions = forwardRef<View, ItemActionsProps>(
323
+ ({ className, children, ...props }, ref) => (
324
+ <View
325
+ ref={ref}
326
+ className={cn('shrink-0 flex-row items-center gap-1.5', className)}
327
+ {...props}
328
+ >
329
+ {children}
330
+ </View>
331
+ )
332
+ );
333
+ ItemActions.displayName = 'Item.Actions';
334
+
335
+ export interface ItemHeaderProps extends ViewProps {
336
+ className?: string;
337
+ children?: ReactNode;
338
+ }
339
+
340
+ /**
341
+ * Full-width strip above the item's main content — an eyebrow label, a badge
342
+ * row. Needs `orientation="vertical"` on the item, since a strip only makes
343
+ * sense once the item stacks.
344
+ */
345
+ const ItemHeader = forwardRef<View, ItemHeaderProps>(
346
+ ({ className, children, ...props }, ref) => (
347
+ <View
348
+ ref={ref}
349
+ className={cn('w-full flex-row items-center justify-between gap-2', className)}
350
+ {...props}
351
+ >
352
+ {children}
353
+ </View>
354
+ )
355
+ );
356
+ ItemHeader.displayName = 'Item.Header';
357
+
358
+ export interface ItemFooterProps extends ViewProps {
359
+ className?: string;
360
+ children?: ReactNode;
361
+ }
362
+
363
+ /** Full-width strip below the row's main content. */
364
+ const ItemFooter = forwardRef<View, ItemFooterProps>(
365
+ ({ className, children, ...props }, ref) => (
366
+ <View
367
+ ref={ref}
368
+ className={cn('w-full flex-row items-center gap-2', className)}
369
+ {...props}
370
+ >
371
+ {children}
372
+ </View>
373
+ )
374
+ );
375
+ ItemFooter.displayName = 'Item.Footer';
376
+
377
+ export const Item = Object.assign(ItemRoot, {
378
+ Group: ItemGroup,
379
+ Separator: ItemSeparator,
380
+ Media: ItemMedia,
381
+ Content: ItemContent,
382
+ Title: ItemTitle,
383
+ Description: ItemDescription,
384
+ Actions: ItemActions,
385
+ Header: ItemHeader,
386
+ Footer: ItemFooter,
387
+ });
@@ -1,9 +1,11 @@
1
1
  /**
2
2
  * Label — text that names a form field.
3
3
  *
4
- * Adapted from: heroui-inc/heroui-native src/components/label/ (root +
5
- * Label.Text anatomy, string children auto-wrapping, the required asterisk,
6
- * and the isInvalid / isDisabled recolouring).
4
+ * A string child is wrapped in `Label.Text` automatically, so the common case
5
+ * stays one line; pass the parts explicitly when the label needs more than
6
+ * text. `isRequired`, `isInvalid` and `isDisabled` are visual *and* semantic —
7
+ * they recolour and they set the accessibility state, so the two cannot drift
8
+ * apart.
7
9
  */
8
10
  import {
9
11
  createContext,
@@ -0,0 +1,312 @@
1
+ /**
2
+ * Message — a chat turn: avatar, bubble, and the metadata around it.
3
+ *
4
+ * The pieces are separate because conversations differ in which ones they
5
+ * need. A support thread wants a sender name and a read receipt; an assistant
6
+ * transcript wants neither, but does want the bubble to carry tool output and
7
+ * actions. Composing them beats a component with a dozen optional props.
8
+ *
9
+ * `align` is the only thing the root decides, and everything downstream reads
10
+ * it from context: which side the row sits on, which way the avatar goes,
11
+ * which corner of the bubble is squared off, and which colour it takes.
12
+ *
13
+ * ```tsx
14
+ * <Message align="end">
15
+ * <Message.Content>
16
+ * <Message.Bubble>
17
+ * <Message.BubbleContent>How can I help?</Message.BubbleContent>
18
+ * </Message.Bubble>
19
+ * </Message.Content>
20
+ * </Message>
21
+ * ```
22
+ */
23
+ import {
24
+ Children,
25
+ cloneElement,
26
+ createContext,
27
+ forwardRef,
28
+ isValidElement,
29
+ useContext,
30
+ type ReactNode,
31
+ } from 'react';
32
+ import { View, type ViewProps } from 'react-native';
33
+ import { tv, type VariantProps } from 'tailwind-variants';
34
+ import { Text, type TextProps } from '../../primitives/text';
35
+ import { cn } from '../../utils/cn';
36
+
37
+ const messageVariants = tv({
38
+ slots: {
39
+ root: 'w-full flex-row items-end gap-2',
40
+ avatar: 'shrink-0',
41
+ content: 'max-w-[85%] gap-1',
42
+ header: 'text-xs font-medium text-muted-foreground',
43
+ bubble: 'rounded-2xl px-3.5 py-2.5',
44
+ bubbleContent: 'text-base',
45
+ footer: 'text-xs text-muted-foreground',
46
+ group: 'w-full gap-1',
47
+ },
48
+ variants: {
49
+ align: {
50
+ start: {
51
+ root: 'flex-row justify-start',
52
+ content: 'items-start',
53
+ // A squared corner on the side the message comes from points the
54
+ // bubble back at its sender.
55
+ bubble: 'rounded-bl-md bg-muted',
56
+ bubbleContent: 'text-foreground',
57
+ header: 'text-left',
58
+ footer: 'text-left',
59
+ },
60
+ end: {
61
+ root: 'flex-row-reverse justify-start',
62
+ content: 'items-end',
63
+ bubble: 'rounded-br-md bg-primary',
64
+ bubbleContent: 'text-primary-foreground',
65
+ header: 'text-right',
66
+ footer: 'text-right',
67
+ },
68
+ },
69
+ },
70
+ defaultVariants: {
71
+ align: 'start',
72
+ },
73
+ });
74
+
75
+ type Align = 'start' | 'end';
76
+
77
+ interface MessageContextValue {
78
+ align: Align;
79
+ /** True when the message is not the first in its group. */
80
+ stacked: boolean;
81
+ }
82
+
83
+ const MessageContext = createContext<MessageContextValue>({
84
+ align: 'start',
85
+ stacked: false,
86
+ });
87
+
88
+ /** Reads the alignment set by the nearest Message. */
89
+ function useMessageContext() {
90
+ return useContext(MessageContext);
91
+ }
92
+
93
+ const GroupContext = createContext<{ align: Align } | null>(null);
94
+
95
+ export interface MessageProps
96
+ extends ViewProps,
97
+ VariantProps<typeof messageVariants> {
98
+ className?: string;
99
+ /**
100
+ * Which side of the conversation this turn belongs to. `end` is the
101
+ * outgoing side — the person holding the device.
102
+ */
103
+ align?: Align;
104
+ /**
105
+ * Continuation of the message above it: tighter spacing, and the avatar slot
106
+ * is reserved but left empty so bubbles stay aligned. `Message.Group` sets
107
+ * this for you.
108
+ */
109
+ stacked?: boolean;
110
+ children?: ReactNode;
111
+ }
112
+
113
+ const MessageRoot = forwardRef<View, MessageProps>(
114
+ ({ className, align, stacked = false, children, ...props }, ref) => {
115
+ const group = useContext(GroupContext);
116
+ const resolvedAlign = align ?? group?.align ?? 'start';
117
+ const { root } = messageVariants({ align: resolvedAlign });
118
+
119
+ return (
120
+ <MessageContext.Provider value={{ align: resolvedAlign, stacked }}>
121
+ <View ref={ref} className={root({ className })} {...props}>
122
+ {children}
123
+ </View>
124
+ </MessageContext.Provider>
125
+ );
126
+ }
127
+ );
128
+ MessageRoot.displayName = 'Message';
129
+
130
+ export interface MessageGroupProps extends ViewProps {
131
+ className?: string;
132
+ /** Alignment applied to every Message inside that does not set its own. */
133
+ align?: Align;
134
+ children?: ReactNode;
135
+ }
136
+
137
+ /**
138
+ * Consecutive turns from one sender. Tightens the spacing between them and
139
+ * marks every message after the first as `stacked`, so only the first shows an
140
+ * avatar and the rest stay aligned with it.
141
+ */
142
+ const MessageGroup = forwardRef<View, MessageGroupProps>(
143
+ ({ className, align = 'start', children, ...props }, ref) => {
144
+ const { group } = messageVariants({ align });
145
+
146
+ // Every turn after the first is a continuation, so it drops its avatar and
147
+ // sits tight against the one above. A child that sets `stacked` itself
148
+ // wins — grouping is a default, not a rule.
149
+ const stackedChildren = Children.map(children, (child, index) =>
150
+ isValidElement<MessageProps>(child) && child.props.stacked === undefined
151
+ ? cloneElement(child, { stacked: index > 0 })
152
+ : child
153
+ );
154
+
155
+ return (
156
+ <GroupContext.Provider value={{ align }}>
157
+ <View ref={ref} className={group({ className })} {...props}>
158
+ {stackedChildren}
159
+ </View>
160
+ </GroupContext.Provider>
161
+ );
162
+ }
163
+ );
164
+ MessageGroup.displayName = 'Message.Group';
165
+
166
+ export interface MessageAvatarProps extends ViewProps {
167
+ className?: string;
168
+ children?: ReactNode;
169
+ }
170
+
171
+ /**
172
+ * Slot for the sender's avatar. On a stacked message the slot is rendered but
173
+ * left empty, which keeps the bubbles in a column instead of letting the
174
+ * second one slide under the first one's avatar.
175
+ */
176
+ const MessageAvatar = forwardRef<View, MessageAvatarProps>(
177
+ ({ className, children, ...props }, ref) => {
178
+ const { stacked } = useMessageContext();
179
+ const { avatar } = messageVariants();
180
+
181
+ return (
182
+ <View ref={ref} className={avatar({ className })} {...props}>
183
+ {stacked ? <View className="opacity-0">{children}</View> : children}
184
+ </View>
185
+ );
186
+ }
187
+ );
188
+ MessageAvatar.displayName = 'Message.Avatar';
189
+
190
+ export interface MessageContentProps extends ViewProps {
191
+ className?: string;
192
+ children?: ReactNode;
193
+ }
194
+
195
+ /** Column holding the header, bubble and footer. */
196
+ const MessageContent = forwardRef<View, MessageContentProps>(
197
+ ({ className, children, ...props }, ref) => {
198
+ const { align } = useMessageContext();
199
+ const { content } = messageVariants({ align });
200
+
201
+ return (
202
+ <View ref={ref} className={content({ className })} {...props}>
203
+ {children}
204
+ </View>
205
+ );
206
+ }
207
+ );
208
+ MessageContent.displayName = 'Message.Content';
209
+
210
+ export interface MessageHeaderProps extends TextProps {
211
+ className?: string;
212
+ }
213
+
214
+ /** Sender name or timestamp above the bubble. */
215
+ const MessageHeader = forwardRef<React.ElementRef<typeof Text>, MessageHeaderProps>(
216
+ ({ className, ...props }, ref) => {
217
+ const { align } = useMessageContext();
218
+ const { header } = messageVariants({ align });
219
+
220
+ return <Text ref={ref} size="xs" className={header({ className })} {...props} />;
221
+ }
222
+ );
223
+ MessageHeader.displayName = 'Message.Header';
224
+
225
+ export interface MessageBubbleProps extends ViewProps {
226
+ className?: string;
227
+ children?: ReactNode;
228
+ }
229
+
230
+ /** The speech bubble. Takes its colour and squared corner from `align`. */
231
+ const MessageBubble = forwardRef<View, MessageBubbleProps>(
232
+ ({ className, children, ...props }, ref) => {
233
+ const { align } = useMessageContext();
234
+ const { bubble } = messageVariants({ align });
235
+
236
+ return (
237
+ <View ref={ref} className={bubble({ className })} {...props}>
238
+ {children}
239
+ </View>
240
+ );
241
+ }
242
+ );
243
+ MessageBubble.displayName = 'Message.Bubble';
244
+
245
+ export interface MessageBubbleContentProps extends TextProps {
246
+ className?: string;
247
+ }
248
+
249
+ /** Text inside the bubble, in whichever colour reads against it. */
250
+ const MessageBubbleContent = forwardRef<
251
+ React.ElementRef<typeof Text>,
252
+ MessageBubbleContentProps
253
+ >(({ className, ...props }, ref) => {
254
+ const { align } = useMessageContext();
255
+ const { bubbleContent } = messageVariants({ align });
256
+
257
+ return <Text ref={ref} className={bubbleContent({ className })} {...props} />;
258
+ });
259
+ MessageBubbleContent.displayName = 'Message.BubbleContent';
260
+
261
+ export interface MessageFooterProps extends TextProps {
262
+ className?: string;
263
+ }
264
+
265
+ /** Delivery state, timestamp or actions below the bubble. */
266
+ const MessageFooter = forwardRef<React.ElementRef<typeof Text>, MessageFooterProps>(
267
+ ({ className, ...props }, ref) => {
268
+ const { align } = useMessageContext();
269
+ const { footer } = messageVariants({ align });
270
+
271
+ return <Text ref={ref} size="xs" className={footer({ className })} {...props} />;
272
+ }
273
+ );
274
+ MessageFooter.displayName = 'Message.Footer';
275
+
276
+ export interface MessageActionsProps extends ViewProps {
277
+ className?: string;
278
+ children?: ReactNode;
279
+ }
280
+
281
+ /** Row of controls under the bubble — copy, retry, feedback. */
282
+ const MessageActions = forwardRef<View, MessageActionsProps>(
283
+ ({ className, children, ...props }, ref) => {
284
+ const { align } = useMessageContext();
285
+
286
+ return (
287
+ <View
288
+ ref={ref}
289
+ className={cn(
290
+ 'flex-row items-center gap-1',
291
+ align === 'end' && 'flex-row-reverse',
292
+ className
293
+ )}
294
+ {...props}
295
+ >
296
+ {children}
297
+ </View>
298
+ );
299
+ }
300
+ );
301
+ MessageActions.displayName = 'Message.Actions';
302
+
303
+ export const Message = Object.assign(MessageRoot, {
304
+ Group: MessageGroup,
305
+ Avatar: MessageAvatar,
306
+ Content: MessageContent,
307
+ Header: MessageHeader,
308
+ Bubble: MessageBubble,
309
+ BubbleContent: MessageBubbleContent,
310
+ Footer: MessageFooter,
311
+ Actions: MessageActions,
312
+ });