panelui-native 0.6.0 → 0.9.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.
- package/README.md +1 -1
- package/lib/module/components/accordion/index.js +7 -5
- package/lib/module/components/accordion/index.js.map +1 -1
- package/lib/module/components/alert/index.js +3 -6
- package/lib/module/components/alert/index.js.map +1 -1
- package/lib/module/components/attachment/index.js +219 -0
- package/lib/module/components/attachment/index.js.map +1 -0
- package/lib/module/components/bottom-sheet/index.js +82 -4
- package/lib/module/components/bottom-sheet/index.js.map +1 -1
- package/lib/module/components/button/index.js +36 -1
- package/lib/module/components/button/index.js.map +1 -1
- package/lib/module/components/dialog/index.js +6 -2
- package/lib/module/components/dialog/index.js.map +1 -1
- package/lib/module/components/empty-state/index.js +98 -22
- package/lib/module/components/empty-state/index.js.map +1 -1
- package/lib/module/components/inline-select/index.js +171 -46
- package/lib/module/components/inline-select/index.js.map +1 -1
- package/lib/module/components/input/index.js +28 -5
- package/lib/module/components/input/index.js.map +1 -1
- package/lib/module/components/input-group/index.js +0 -1
- package/lib/module/components/input-group/index.js.map +1 -1
- package/lib/module/components/item/index.js +359 -0
- package/lib/module/components/item/index.js.map +1 -0
- package/lib/module/components/label/index.js +5 -3
- package/lib/module/components/label/index.js.map +1 -1
- package/lib/module/components/line-chart/index.js +1024 -0
- package/lib/module/components/line-chart/index.js.map +1 -0
- package/lib/module/components/marker/index.js +190 -0
- package/lib/module/components/marker/index.js.map +1 -0
- package/lib/module/components/message/index.js +334 -0
- package/lib/module/components/message/index.js.map +1 -0
- package/lib/module/components/message-scroller/index.js +451 -0
- package/lib/module/components/message-scroller/index.js.map +1 -0
- package/lib/module/components/popover/index.js +500 -0
- package/lib/module/components/popover/index.js.map +1 -0
- package/lib/module/components/radio-group/index.js +104 -16
- package/lib/module/components/radio-group/index.js.map +1 -1
- package/lib/module/components/scroll-fade/index.js +119 -71
- package/lib/module/components/scroll-fade/index.js.map +1 -1
- package/lib/module/components/select/index.js +35 -6
- package/lib/module/components/select/index.js.map +1 -1
- package/lib/module/components/separator/index.js +91 -0
- package/lib/module/components/separator/index.js.map +1 -0
- package/lib/module/components/shimmer/index.js +111 -64
- package/lib/module/components/shimmer/index.js.map +1 -1
- package/lib/module/components/surface/index.js +24 -4
- package/lib/module/components/surface/index.js.map +1 -1
- package/lib/module/components/switch/index.js +20 -1
- package/lib/module/components/switch/index.js.map +1 -1
- package/lib/module/components/toast/index.js +7 -8
- package/lib/module/components/toast/index.js.map +1 -1
- package/lib/module/components/typography/index.js +3 -3
- package/lib/module/hooks/index.js +1 -0
- package/lib/module/hooks/index.js.map +1 -1
- package/lib/module/hooks/use-breakpoint.js +2 -2
- package/lib/module/hooks/use-copy-to-clipboard.js +0 -2
- package/lib/module/hooks/use-copy-to-clipboard.js.map +1 -1
- package/lib/module/hooks/use-keyboard-avoidance.js +120 -0
- package/lib/module/hooks/use-keyboard-avoidance.js.map +1 -0
- package/lib/module/icons/index.js +73 -4
- package/lib/module/icons/index.js.map +1 -1
- package/lib/module/index.js +13 -1
- package/lib/module/index.js.map +1 -1
- package/lib/module/native/index.js +53 -0
- package/lib/module/native/index.js.map +1 -0
- package/lib/module/primitives/keyboard-avoider.js +40 -0
- package/lib/module/primitives/keyboard-avoider.js.map +1 -0
- package/lib/module/primitives/scrim.js +72 -0
- package/lib/module/primitives/scrim.js.map +1 -0
- package/lib/module/providers/panel-ui-provider.js +24 -4
- package/lib/module/providers/panel-ui-provider.js.map +1 -1
- package/lib/module/theme/use-theme.js +1 -1
- package/lib/typescript/src/components/accordion/index.d.ts +7 -5
- package/lib/typescript/src/components/accordion/index.d.ts.map +1 -1
- package/lib/typescript/src/components/alert/index.d.ts +3 -6
- package/lib/typescript/src/components/alert/index.d.ts.map +1 -1
- package/lib/typescript/src/components/attachment/index.d.ts +93 -0
- package/lib/typescript/src/components/attachment/index.d.ts.map +1 -0
- package/lib/typescript/src/components/bottom-sheet/index.d.ts +27 -2
- package/lib/typescript/src/components/bottom-sheet/index.d.ts.map +1 -1
- package/lib/typescript/src/components/button/index.d.ts +11 -0
- package/lib/typescript/src/components/button/index.d.ts.map +1 -1
- package/lib/typescript/src/components/dialog/index.d.ts +7 -1
- package/lib/typescript/src/components/dialog/index.d.ts.map +1 -1
- package/lib/typescript/src/components/empty-state/index.d.ts +156 -7
- package/lib/typescript/src/components/empty-state/index.d.ts.map +1 -1
- package/lib/typescript/src/components/inline-select/index.d.ts +29 -6
- package/lib/typescript/src/components/inline-select/index.d.ts.map +1 -1
- package/lib/typescript/src/components/input/index.d.ts +13 -0
- package/lib/typescript/src/components/input/index.d.ts.map +1 -1
- package/lib/typescript/src/components/input-group/index.d.ts +0 -1
- package/lib/typescript/src/components/input-group/index.d.ts.map +1 -1
- package/lib/typescript/src/components/item/index.d.ts +286 -0
- package/lib/typescript/src/components/item/index.d.ts.map +1 -0
- package/lib/typescript/src/components/label/index.d.ts +5 -3
- package/lib/typescript/src/components/label/index.d.ts.map +1 -1
- package/lib/typescript/src/components/line-chart/index.d.ts +247 -0
- package/lib/typescript/src/components/line-chart/index.d.ts.map +1 -0
- package/lib/typescript/src/components/marker/index.d.ts +137 -0
- package/lib/typescript/src/components/marker/index.d.ts.map +1 -0
- package/lib/typescript/src/components/message/index.d.ts +177 -0
- package/lib/typescript/src/components/message/index.d.ts.map +1 -0
- package/lib/typescript/src/components/message-scroller/index.d.ts +150 -0
- package/lib/typescript/src/components/message-scroller/index.d.ts.map +1 -0
- package/lib/typescript/src/components/popover/index.d.ts +130 -0
- package/lib/typescript/src/components/popover/index.d.ts.map +1 -0
- package/lib/typescript/src/components/radio-group/index.d.ts +32 -0
- package/lib/typescript/src/components/radio-group/index.d.ts.map +1 -1
- package/lib/typescript/src/components/scroll-fade/index.d.ts +18 -9
- package/lib/typescript/src/components/scroll-fade/index.d.ts.map +1 -1
- package/lib/typescript/src/components/select/index.d.ts +16 -1
- package/lib/typescript/src/components/select/index.d.ts.map +1 -1
- package/lib/typescript/src/components/separator/index.d.ts +47 -0
- package/lib/typescript/src/components/separator/index.d.ts.map +1 -0
- package/lib/typescript/src/components/shimmer/index.d.ts +38 -20
- package/lib/typescript/src/components/shimmer/index.d.ts.map +1 -1
- package/lib/typescript/src/components/surface/index.d.ts +51 -2
- package/lib/typescript/src/components/surface/index.d.ts.map +1 -1
- package/lib/typescript/src/components/switch/index.d.ts +10 -0
- package/lib/typescript/src/components/switch/index.d.ts.map +1 -1
- package/lib/typescript/src/components/toast/index.d.ts +4 -4
- package/lib/typescript/src/components/toast/index.d.ts.map +1 -1
- package/lib/typescript/src/hooks/index.d.ts +1 -0
- package/lib/typescript/src/hooks/index.d.ts.map +1 -1
- package/lib/typescript/src/hooks/use-breakpoint.d.ts +2 -2
- package/lib/typescript/src/hooks/use-copy-to-clipboard.d.ts +0 -2
- package/lib/typescript/src/hooks/use-copy-to-clipboard.d.ts.map +1 -1
- package/lib/typescript/src/hooks/use-keyboard-avoidance.d.ts +20 -0
- package/lib/typescript/src/hooks/use-keyboard-avoidance.d.ts.map +1 -0
- package/lib/typescript/src/icons/index.d.ts +10 -4
- package/lib/typescript/src/icons/index.d.ts.map +1 -1
- package/lib/typescript/src/index.d.ts +11 -1
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/lib/typescript/src/native/index.d.ts +35 -0
- package/lib/typescript/src/native/index.d.ts.map +1 -0
- package/lib/typescript/src/primitives/keyboard-avoider.d.ts +22 -0
- package/lib/typescript/src/primitives/keyboard-avoider.d.ts.map +1 -0
- package/lib/typescript/src/primitives/scrim.d.ts +21 -0
- package/lib/typescript/src/primitives/scrim.d.ts.map +1 -0
- package/lib/typescript/src/providers/panel-ui-provider.d.ts +1 -1
- package/lib/typescript/src/providers/panel-ui-provider.d.ts.map +1 -1
- package/lib/typescript/src/theme/use-theme.d.ts +1 -1
- package/package.json +16 -3
- package/src/components/accordion/index.tsx +7 -5
- package/src/components/alert/index.tsx +3 -6
- package/src/components/attachment/index.tsx +279 -0
- package/src/components/bottom-sheet/index.tsx +102 -2
- package/src/components/button/index.tsx +52 -0
- package/src/components/dialog/index.tsx +10 -1
- package/src/components/empty-state/index.tsx +64 -19
- package/src/components/inline-select/index.tsx +191 -44
- package/src/components/input/index.tsx +39 -3
- package/src/components/input-group/index.tsx +0 -1
- package/src/components/item/index.tsx +387 -0
- package/src/components/label/index.tsx +5 -3
- package/src/components/line-chart/index.tsx +1224 -0
- package/src/components/marker/index.tsx +194 -0
- package/src/components/message/index.tsx +336 -0
- package/src/components/message-scroller/index.tsx +574 -0
- package/src/components/popover/index.tsx +599 -0
- package/src/components/radio-group/index.tsx +108 -17
- package/src/components/scroll-fade/index.tsx +163 -51
- package/src/components/select/index.tsx +54 -9
- package/src/components/separator/index.tsx +97 -0
- package/src/components/shimmer/index.tsx +151 -58
- package/src/components/surface/index.tsx +32 -5
- package/src/components/switch/index.tsx +27 -1
- package/src/components/toast/index.tsx +7 -8
- package/src/components/typography/index.tsx +3 -3
- package/src/hooks/index.ts +5 -0
- package/src/hooks/use-breakpoint.ts +2 -2
- package/src/hooks/use-copy-to-clipboard.ts +0 -2
- package/src/hooks/use-keyboard-avoidance.ts +140 -0
- package/src/icons/index.tsx +52 -4
- package/src/index.ts +92 -0
- package/src/native/index.ts +79 -0
- package/src/primitives/keyboard-avoider.tsx +49 -0
- package/src/primitives/scrim.tsx +95 -0
- package/src/providers/panel-ui-provider.tsx +30 -9
- package/src/theme/use-theme.ts +1 -1
- package/theme.css +63 -3
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Marker — an inline note in a conversation.
|
|
3
|
+
*
|
|
4
|
+
* Transcripts are not only messages. Between the turns sit things the
|
|
5
|
+
* conversation did rather than said: a tool that ran, a file that was read, a
|
|
6
|
+
* model that changed, the point where yesterday ended. Those are not messages
|
|
7
|
+
* and should not look like one — a bubble implies a speaker, and none of these
|
|
8
|
+
* have one. A marker is the quieter row that carries them: small, muted, and
|
|
9
|
+
* aligned to the transcript rather than to a side of it.
|
|
10
|
+
*
|
|
11
|
+
* ```tsx
|
|
12
|
+
* <Marker>
|
|
13
|
+
* <Marker.Icon><CheckIcon /></Marker.Icon>
|
|
14
|
+
* <Marker.Content>Explored 4 files</Marker.Content>
|
|
15
|
+
* </Marker>
|
|
16
|
+
* ```
|
|
17
|
+
*
|
|
18
|
+
* `shimmer` on the content is for the row that is still happening — the sweep
|
|
19
|
+
* runs through the glyphs while a step is in flight, and is dropped the moment
|
|
20
|
+
* it finishes.
|
|
21
|
+
*/
|
|
22
|
+
import { createContext, forwardRef, useContext, type ReactNode } from 'react';
|
|
23
|
+
import { View, type Text as RNText, type ViewProps } from 'react-native';
|
|
24
|
+
import { tv, type VariantProps } from 'tailwind-variants';
|
|
25
|
+
import { useCSSVariable } from 'uniwind';
|
|
26
|
+
import { IconColorProvider } from '../../icons';
|
|
27
|
+
import { AnimatedPressable, type AnimatedPressableProps } from '../../primitives/animated-pressable';
|
|
28
|
+
import { Text, type TextProps } from '../../primitives/text';
|
|
29
|
+
import { cn } from '../../utils/cn';
|
|
30
|
+
import { Separator } from '../separator';
|
|
31
|
+
import { Shimmer } from '../shimmer';
|
|
32
|
+
|
|
33
|
+
type MarkerVariant = 'default' | 'border' | 'separator';
|
|
34
|
+
|
|
35
|
+
const markerVariants = tv({
|
|
36
|
+
slots: {
|
|
37
|
+
root: 'w-full flex-row items-center gap-2 py-1.5',
|
|
38
|
+
content: 'text-sm text-muted-foreground',
|
|
39
|
+
},
|
|
40
|
+
variants: {
|
|
41
|
+
variant: {
|
|
42
|
+
/** The plain row: an icon and a line of muted text. */
|
|
43
|
+
default: {},
|
|
44
|
+
/** Same row, closed by a hairline — for a step that ends a section. */
|
|
45
|
+
border: { root: 'border-b border-border pb-2.5' },
|
|
46
|
+
/**
|
|
47
|
+
* A centred label with a rule running out to each edge. The rules are
|
|
48
|
+
* `Separator`s rather than borders so they follow the same token and
|
|
49
|
+
* thickness as every other rule in the app.
|
|
50
|
+
*/
|
|
51
|
+
separator: { root: 'gap-3 py-3', content: 'text-xs uppercase tracking-wider' },
|
|
52
|
+
},
|
|
53
|
+
disabled: {
|
|
54
|
+
true: { root: 'opacity-[0.64]' },
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
defaultVariants: {
|
|
58
|
+
variant: 'default',
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
/** The parts read the variant rather than being told it twice. */
|
|
63
|
+
const MarkerContext = createContext<MarkerVariant>('default');
|
|
64
|
+
|
|
65
|
+
export interface MarkerProps
|
|
66
|
+
extends Omit<AnimatedPressableProps, 'children' | 'disabled'>,
|
|
67
|
+
Omit<VariantProps<typeof markerVariants>, 'disabled'> {
|
|
68
|
+
className?: string;
|
|
69
|
+
disabled?: boolean;
|
|
70
|
+
/**
|
|
71
|
+
* `default` is the inline status row. `border` closes it with a hairline.
|
|
72
|
+
* `separator` centres the content between two rules — the "Yesterday"
|
|
73
|
+
* divider shape.
|
|
74
|
+
*/
|
|
75
|
+
variant?: MarkerVariant;
|
|
76
|
+
children?: ReactNode;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Renders as a pressable when given `onPress` and as a plain view otherwise,
|
|
81
|
+
* so a marker that does nothing does not announce itself as a button.
|
|
82
|
+
*/
|
|
83
|
+
const MarkerRoot = forwardRef<View, MarkerProps>(
|
|
84
|
+
({ className, variant = 'default', disabled, children, onPress, ...props }, ref) => {
|
|
85
|
+
const { root } = markerVariants({ variant, disabled: !!disabled });
|
|
86
|
+
|
|
87
|
+
// The rules belong to the root, not the content: the content has to stay a
|
|
88
|
+
// single centred child for `flex-1` on either side to split the remainder.
|
|
89
|
+
const inner =
|
|
90
|
+
variant === 'separator' ? (
|
|
91
|
+
<>
|
|
92
|
+
<Separator className="flex-1" />
|
|
93
|
+
{children}
|
|
94
|
+
<Separator className="flex-1" />
|
|
95
|
+
</>
|
|
96
|
+
) : (
|
|
97
|
+
children
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
const body = !onPress ? (
|
|
101
|
+
<View
|
|
102
|
+
ref={ref}
|
|
103
|
+
accessibilityState={{ disabled: !!disabled }}
|
|
104
|
+
className={root({ className })}
|
|
105
|
+
{...(props as ViewProps)}
|
|
106
|
+
>
|
|
107
|
+
{inner}
|
|
108
|
+
</View>
|
|
109
|
+
) : (
|
|
110
|
+
<AnimatedPressable
|
|
111
|
+
ref={ref}
|
|
112
|
+
accessibilityRole="button"
|
|
113
|
+
accessibilityState={{ disabled: !!disabled }}
|
|
114
|
+
disabled={disabled}
|
|
115
|
+
onPress={onPress}
|
|
116
|
+
className={root({ className })}
|
|
117
|
+
{...props}
|
|
118
|
+
>
|
|
119
|
+
{inner}
|
|
120
|
+
</AnimatedPressable>
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
return <MarkerContext.Provider value={variant}>{body}</MarkerContext.Provider>;
|
|
124
|
+
}
|
|
125
|
+
);
|
|
126
|
+
MarkerRoot.displayName = 'Marker';
|
|
127
|
+
|
|
128
|
+
export interface MarkerIconProps extends ViewProps {
|
|
129
|
+
className?: string;
|
|
130
|
+
children?: ReactNode;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Decorative leading slot. Hidden from screen readers — the icon repeats what
|
|
135
|
+
* the content already says, and announcing it twice is noise.
|
|
136
|
+
*/
|
|
137
|
+
const MarkerIcon = forwardRef<View, MarkerIconProps>(
|
|
138
|
+
({ className, children, ...props }, ref) => {
|
|
139
|
+
// Icons here take the muted foreground, so a marker reads as chrome next to
|
|
140
|
+
// a message rather than competing with it.
|
|
141
|
+
const muted = useCSSVariable('--color-muted-foreground');
|
|
142
|
+
|
|
143
|
+
return (
|
|
144
|
+
<View
|
|
145
|
+
ref={ref}
|
|
146
|
+
accessibilityElementsHidden
|
|
147
|
+
importantForAccessibility="no-hide-descendants"
|
|
148
|
+
className={cn('h-4 w-4 shrink-0 items-center justify-center', className)}
|
|
149
|
+
{...props}
|
|
150
|
+
>
|
|
151
|
+
<IconColorProvider color={typeof muted === 'string' ? muted : undefined}>
|
|
152
|
+
{children}
|
|
153
|
+
</IconColorProvider>
|
|
154
|
+
</View>
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
);
|
|
158
|
+
MarkerIcon.displayName = 'Marker.Icon';
|
|
159
|
+
|
|
160
|
+
export interface MarkerContentProps extends TextProps {
|
|
161
|
+
className?: string;
|
|
162
|
+
/**
|
|
163
|
+
* Sweep a highlight through the text, for a step that is still running.
|
|
164
|
+
* Drop it when the step finishes — a marker that shimmers forever reads as
|
|
165
|
+
* a stuck one.
|
|
166
|
+
*/
|
|
167
|
+
shimmer?: boolean;
|
|
168
|
+
children?: ReactNode;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const MarkerContent = forwardRef<RNText, MarkerContentProps>(
|
|
172
|
+
({ className, shimmer, children, ...props }, ref) => {
|
|
173
|
+
const variant = useContext(MarkerContext);
|
|
174
|
+
const { content } = markerVariants({ variant });
|
|
175
|
+
|
|
176
|
+
if (shimmer) {
|
|
177
|
+
// Shimmer owns the text so it can mask the sweep to the glyphs; the
|
|
178
|
+
// class goes to the text inside it rather than to the wrapping view.
|
|
179
|
+
return <Shimmer textClassName={content({ className })}>{children}</Shimmer>;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return (
|
|
183
|
+
<Text ref={ref} className={content({ className })} {...props}>
|
|
184
|
+
{children}
|
|
185
|
+
</Text>
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
);
|
|
189
|
+
MarkerContent.displayName = 'Marker.Content';
|
|
190
|
+
|
|
191
|
+
export const Marker = Object.assign(MarkerRoot, {
|
|
192
|
+
Icon: MarkerIcon,
|
|
193
|
+
Content: MarkerContent,
|
|
194
|
+
});
|
|
@@ -0,0 +1,336 @@
|
|
|
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 GestureResponderEvent, type ViewProps } from 'react-native';
|
|
33
|
+
import { tv, type VariantProps } from 'tailwind-variants';
|
|
34
|
+
import { AnimatedPressable } from '../../primitives/animated-pressable';
|
|
35
|
+
import { Text, type TextProps } from '../../primitives/text';
|
|
36
|
+
import { cn } from '../../utils/cn';
|
|
37
|
+
|
|
38
|
+
const messageVariants = tv({
|
|
39
|
+
slots: {
|
|
40
|
+
root: 'w-full flex-row items-end gap-2',
|
|
41
|
+
avatar: 'shrink-0',
|
|
42
|
+
content: 'max-w-[85%] gap-1',
|
|
43
|
+
header: 'text-xs font-medium text-muted-foreground',
|
|
44
|
+
bubble: 'rounded-2xl px-3.5 py-2.5',
|
|
45
|
+
bubbleContent: 'text-base',
|
|
46
|
+
footer: 'text-xs text-muted-foreground',
|
|
47
|
+
group: 'w-full gap-1',
|
|
48
|
+
},
|
|
49
|
+
variants: {
|
|
50
|
+
align: {
|
|
51
|
+
start: {
|
|
52
|
+
root: 'flex-row justify-start',
|
|
53
|
+
content: 'items-start',
|
|
54
|
+
// A squared corner on the side the message comes from points the
|
|
55
|
+
// bubble back at its sender.
|
|
56
|
+
bubble: 'rounded-bl-md bg-muted',
|
|
57
|
+
bubbleContent: 'text-foreground',
|
|
58
|
+
header: 'text-left',
|
|
59
|
+
footer: 'text-left',
|
|
60
|
+
},
|
|
61
|
+
end: {
|
|
62
|
+
root: 'flex-row-reverse justify-start',
|
|
63
|
+
content: 'items-end',
|
|
64
|
+
bubble: 'rounded-br-md bg-primary',
|
|
65
|
+
bubbleContent: 'text-primary-foreground',
|
|
66
|
+
header: 'text-right',
|
|
67
|
+
footer: 'text-right',
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
defaultVariants: {
|
|
72
|
+
align: 'start',
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
type Align = 'start' | 'end';
|
|
77
|
+
|
|
78
|
+
interface MessageContextValue {
|
|
79
|
+
align: Align;
|
|
80
|
+
/** True when the message is not the first in its group. */
|
|
81
|
+
stacked: boolean;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const MessageContext = createContext<MessageContextValue>({
|
|
85
|
+
align: 'start',
|
|
86
|
+
stacked: false,
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
/** Reads the alignment set by the nearest Message. */
|
|
90
|
+
function useMessageContext() {
|
|
91
|
+
return useContext(MessageContext);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const GroupContext = createContext<{ align: Align } | null>(null);
|
|
95
|
+
|
|
96
|
+
export interface MessageProps
|
|
97
|
+
extends ViewProps,
|
|
98
|
+
VariantProps<typeof messageVariants> {
|
|
99
|
+
className?: string;
|
|
100
|
+
/**
|
|
101
|
+
* Which side of the conversation this turn belongs to. `end` is the
|
|
102
|
+
* outgoing side — the person holding the device.
|
|
103
|
+
*/
|
|
104
|
+
align?: Align;
|
|
105
|
+
/**
|
|
106
|
+
* Continuation of the message above it: tighter spacing, and the avatar slot
|
|
107
|
+
* is reserved but left empty so bubbles stay aligned. `Message.Group` sets
|
|
108
|
+
* this for you.
|
|
109
|
+
*/
|
|
110
|
+
stacked?: boolean;
|
|
111
|
+
/**
|
|
112
|
+
* Fires on a long press anywhere on the turn — the gesture a chat uses to
|
|
113
|
+
* surface per-message actions (copy, reply, react). Open a menu from it; the
|
|
114
|
+
* component only exposes the press. When set, the whole row gains press
|
|
115
|
+
* feedback. A plain tap should still do nothing, so there is no `onPress`.
|
|
116
|
+
*/
|
|
117
|
+
onLongPress?: (event: GestureResponderEvent) => void;
|
|
118
|
+
children?: ReactNode;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const MessageRoot = forwardRef<View, MessageProps>(
|
|
122
|
+
({ className, align, stacked = false, onLongPress, children, ...props }, ref) => {
|
|
123
|
+
const group = useContext(GroupContext);
|
|
124
|
+
const resolvedAlign = align ?? group?.align ?? 'start';
|
|
125
|
+
const { root } = messageVariants({ align: resolvedAlign });
|
|
126
|
+
|
|
127
|
+
const body = onLongPress ? (
|
|
128
|
+
// A long-press target, not a button — a tap does nothing, so no role is
|
|
129
|
+
// announced. The feedback matches every other pressable in the library.
|
|
130
|
+
<AnimatedPressable
|
|
131
|
+
ref={ref}
|
|
132
|
+
onLongPress={onLongPress}
|
|
133
|
+
delayLongPress={300}
|
|
134
|
+
className={root({ className })}
|
|
135
|
+
{...props}
|
|
136
|
+
>
|
|
137
|
+
{children}
|
|
138
|
+
</AnimatedPressable>
|
|
139
|
+
) : (
|
|
140
|
+
<View ref={ref} className={root({ className })} {...props}>
|
|
141
|
+
{children}
|
|
142
|
+
</View>
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
return (
|
|
146
|
+
<MessageContext.Provider value={{ align: resolvedAlign, stacked }}>
|
|
147
|
+
{body}
|
|
148
|
+
</MessageContext.Provider>
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
);
|
|
152
|
+
MessageRoot.displayName = 'Message';
|
|
153
|
+
|
|
154
|
+
export interface MessageGroupProps extends ViewProps {
|
|
155
|
+
className?: string;
|
|
156
|
+
/** Alignment applied to every Message inside that does not set its own. */
|
|
157
|
+
align?: Align;
|
|
158
|
+
children?: ReactNode;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Consecutive turns from one sender. Tightens the spacing between them and
|
|
163
|
+
* marks every message after the first as `stacked`, so only the first shows an
|
|
164
|
+
* avatar and the rest stay aligned with it.
|
|
165
|
+
*/
|
|
166
|
+
const MessageGroup = forwardRef<View, MessageGroupProps>(
|
|
167
|
+
({ className, align = 'start', children, ...props }, ref) => {
|
|
168
|
+
const { group } = messageVariants({ align });
|
|
169
|
+
|
|
170
|
+
// Every turn after the first is a continuation, so it drops its avatar and
|
|
171
|
+
// sits tight against the one above. A child that sets `stacked` itself
|
|
172
|
+
// wins — grouping is a default, not a rule.
|
|
173
|
+
const stackedChildren = Children.map(children, (child, index) =>
|
|
174
|
+
isValidElement<MessageProps>(child) && child.props.stacked === undefined
|
|
175
|
+
? cloneElement(child, { stacked: index > 0 })
|
|
176
|
+
: child
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
return (
|
|
180
|
+
<GroupContext.Provider value={{ align }}>
|
|
181
|
+
<View ref={ref} className={group({ className })} {...props}>
|
|
182
|
+
{stackedChildren}
|
|
183
|
+
</View>
|
|
184
|
+
</GroupContext.Provider>
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
);
|
|
188
|
+
MessageGroup.displayName = 'Message.Group';
|
|
189
|
+
|
|
190
|
+
export interface MessageAvatarProps extends ViewProps {
|
|
191
|
+
className?: string;
|
|
192
|
+
children?: ReactNode;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Slot for the sender's avatar. On a stacked message the slot is rendered but
|
|
197
|
+
* left empty, which keeps the bubbles in a column instead of letting the
|
|
198
|
+
* second one slide under the first one's avatar.
|
|
199
|
+
*/
|
|
200
|
+
const MessageAvatar = forwardRef<View, MessageAvatarProps>(
|
|
201
|
+
({ className, children, ...props }, ref) => {
|
|
202
|
+
const { stacked } = useMessageContext();
|
|
203
|
+
const { avatar } = messageVariants();
|
|
204
|
+
|
|
205
|
+
return (
|
|
206
|
+
<View ref={ref} className={avatar({ className })} {...props}>
|
|
207
|
+
{stacked ? <View className="opacity-0">{children}</View> : children}
|
|
208
|
+
</View>
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
);
|
|
212
|
+
MessageAvatar.displayName = 'Message.Avatar';
|
|
213
|
+
|
|
214
|
+
export interface MessageContentProps extends ViewProps {
|
|
215
|
+
className?: string;
|
|
216
|
+
children?: ReactNode;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Column holding the header, bubble and footer. */
|
|
220
|
+
const MessageContent = forwardRef<View, MessageContentProps>(
|
|
221
|
+
({ className, children, ...props }, ref) => {
|
|
222
|
+
const { align } = useMessageContext();
|
|
223
|
+
const { content } = messageVariants({ align });
|
|
224
|
+
|
|
225
|
+
return (
|
|
226
|
+
<View ref={ref} className={content({ className })} {...props}>
|
|
227
|
+
{children}
|
|
228
|
+
</View>
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
);
|
|
232
|
+
MessageContent.displayName = 'Message.Content';
|
|
233
|
+
|
|
234
|
+
export interface MessageHeaderProps extends TextProps {
|
|
235
|
+
className?: string;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Sender name or timestamp above the bubble. */
|
|
239
|
+
const MessageHeader = forwardRef<React.ElementRef<typeof Text>, MessageHeaderProps>(
|
|
240
|
+
({ className, ...props }, ref) => {
|
|
241
|
+
const { align } = useMessageContext();
|
|
242
|
+
const { header } = messageVariants({ align });
|
|
243
|
+
|
|
244
|
+
return <Text ref={ref} size="xs" className={header({ className })} {...props} />;
|
|
245
|
+
}
|
|
246
|
+
);
|
|
247
|
+
MessageHeader.displayName = 'Message.Header';
|
|
248
|
+
|
|
249
|
+
export interface MessageBubbleProps extends ViewProps {
|
|
250
|
+
className?: string;
|
|
251
|
+
children?: ReactNode;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** The speech bubble. Takes its colour and squared corner from `align`. */
|
|
255
|
+
const MessageBubble = forwardRef<View, MessageBubbleProps>(
|
|
256
|
+
({ className, children, ...props }, ref) => {
|
|
257
|
+
const { align } = useMessageContext();
|
|
258
|
+
const { bubble } = messageVariants({ align });
|
|
259
|
+
|
|
260
|
+
return (
|
|
261
|
+
<View ref={ref} className={bubble({ className })} {...props}>
|
|
262
|
+
{children}
|
|
263
|
+
</View>
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
);
|
|
267
|
+
MessageBubble.displayName = 'Message.Bubble';
|
|
268
|
+
|
|
269
|
+
export interface MessageBubbleContentProps extends TextProps {
|
|
270
|
+
className?: string;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** Text inside the bubble, in whichever colour reads against it. */
|
|
274
|
+
const MessageBubbleContent = forwardRef<
|
|
275
|
+
React.ElementRef<typeof Text>,
|
|
276
|
+
MessageBubbleContentProps
|
|
277
|
+
>(({ className, ...props }, ref) => {
|
|
278
|
+
const { align } = useMessageContext();
|
|
279
|
+
const { bubbleContent } = messageVariants({ align });
|
|
280
|
+
|
|
281
|
+
return <Text ref={ref} className={bubbleContent({ className })} {...props} />;
|
|
282
|
+
});
|
|
283
|
+
MessageBubbleContent.displayName = 'Message.BubbleContent';
|
|
284
|
+
|
|
285
|
+
export interface MessageFooterProps extends TextProps {
|
|
286
|
+
className?: string;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** Delivery state, timestamp or actions below the bubble. */
|
|
290
|
+
const MessageFooter = forwardRef<React.ElementRef<typeof Text>, MessageFooterProps>(
|
|
291
|
+
({ className, ...props }, ref) => {
|
|
292
|
+
const { align } = useMessageContext();
|
|
293
|
+
const { footer } = messageVariants({ align });
|
|
294
|
+
|
|
295
|
+
return <Text ref={ref} size="xs" className={footer({ className })} {...props} />;
|
|
296
|
+
}
|
|
297
|
+
);
|
|
298
|
+
MessageFooter.displayName = 'Message.Footer';
|
|
299
|
+
|
|
300
|
+
export interface MessageActionsProps extends ViewProps {
|
|
301
|
+
className?: string;
|
|
302
|
+
children?: ReactNode;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** Row of controls under the bubble — copy, retry, feedback. */
|
|
306
|
+
const MessageActions = forwardRef<View, MessageActionsProps>(
|
|
307
|
+
({ className, children, ...props }, ref) => {
|
|
308
|
+
const { align } = useMessageContext();
|
|
309
|
+
|
|
310
|
+
return (
|
|
311
|
+
<View
|
|
312
|
+
ref={ref}
|
|
313
|
+
className={cn(
|
|
314
|
+
'flex-row items-center gap-1',
|
|
315
|
+
align === 'end' && 'flex-row-reverse',
|
|
316
|
+
className
|
|
317
|
+
)}
|
|
318
|
+
{...props}
|
|
319
|
+
>
|
|
320
|
+
{children}
|
|
321
|
+
</View>
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
);
|
|
325
|
+
MessageActions.displayName = 'Message.Actions';
|
|
326
|
+
|
|
327
|
+
export const Message = Object.assign(MessageRoot, {
|
|
328
|
+
Group: MessageGroup,
|
|
329
|
+
Avatar: MessageAvatar,
|
|
330
|
+
Content: MessageContent,
|
|
331
|
+
Header: MessageHeader,
|
|
332
|
+
Bubble: MessageBubble,
|
|
333
|
+
BubbleContent: MessageBubbleContent,
|
|
334
|
+
Footer: MessageFooter,
|
|
335
|
+
Actions: MessageActions,
|
|
336
|
+
});
|