panelui-native 0.5.3 → 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.
- package/README.md +9 -5
- 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/bottom-sheet/index.js +67 -3
- 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/empty-state/index.js +4 -5
- 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/message/index.js +318 -0
- package/lib/module/components/message/index.js.map +1 -0
- package/lib/module/components/scroll-fade/index.js +204 -0
- package/lib/module/components/scroll-fade/index.js.map +1 -0
- package/lib/module/components/select/index.js +35 -6
- package/lib/module/components/select/index.js.map +1 -1
- package/lib/module/components/shimmer/index.js +167 -0
- package/lib/module/components/shimmer/index.js.map +1 -0
- package/lib/module/components/surface/index.js +65 -0
- package/lib/module/components/surface/index.js.map +1 -0
- 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 +10 -0
- package/lib/module/hooks/index.js.map +1 -0
- package/lib/module/hooks/use-breakpoint.js +39 -0
- package/lib/module/hooks/use-breakpoint.js.map +1 -0
- package/lib/module/hooks/use-copy-to-clipboard.js +60 -0
- package/lib/module/hooks/use-copy-to-clipboard.js.map +1 -0
- package/lib/module/hooks/use-debounced-value.js +24 -0
- package/lib/module/hooks/use-debounced-value.js.map +1 -0
- package/lib/module/hooks/use-disclosure.js +43 -0
- package/lib/module/hooks/use-disclosure.js.map +1 -0
- package/lib/module/hooks/use-keyboard-avoidance.js +120 -0
- package/lib/module/hooks/use-keyboard-avoidance.js.map +1 -0
- package/lib/module/hooks/use-keyboard.js +36 -0
- package/lib/module/hooks/use-keyboard.js.map +1 -0
- package/lib/module/hooks/use-previous.js +25 -0
- package/lib/module/hooks/use-previous.js.map +1 -0
- package/lib/module/icons/index.js +4 -4
- package/lib/module/index.js +12 -0
- 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/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/bottom-sheet/index.d.ts +21 -1
- 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/empty-state/index.d.ts +4 -5
- 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/message/index.d.ts +170 -0
- package/lib/typescript/src/components/message/index.d.ts.map +1 -0
- package/lib/typescript/src/components/scroll-fade/index.d.ts +47 -0
- package/lib/typescript/src/components/scroll-fade/index.d.ts.map +1 -0
- 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/shimmer/index.d.ts +60 -0
- package/lib/typescript/src/components/shimmer/index.d.ts.map +1 -0
- package/lib/typescript/src/components/surface/index.d.ts +30 -0
- package/lib/typescript/src/components/surface/index.d.ts.map +1 -0
- 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 +8 -0
- package/lib/typescript/src/hooks/index.d.ts.map +1 -0
- package/lib/typescript/src/hooks/use-breakpoint.d.ts +32 -0
- package/lib/typescript/src/hooks/use-breakpoint.d.ts.map +1 -0
- package/lib/typescript/src/hooks/use-copy-to-clipboard.d.ts +28 -0
- package/lib/typescript/src/hooks/use-copy-to-clipboard.d.ts.map +1 -0
- package/lib/typescript/src/hooks/use-debounced-value.d.ts +13 -0
- package/lib/typescript/src/hooks/use-debounced-value.d.ts.map +1 -0
- package/lib/typescript/src/hooks/use-disclosure.d.ts +32 -0
- package/lib/typescript/src/hooks/use-disclosure.d.ts.map +1 -0
- 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/hooks/use-keyboard.d.ts +20 -0
- package/lib/typescript/src/hooks/use-keyboard.d.ts.map +1 -0
- package/lib/typescript/src/hooks/use-previous.d.ts +15 -0
- package/lib/typescript/src/hooks/use-previous.d.ts.map +1 -0
- package/lib/typescript/src/icons/index.d.ts +4 -4
- package/lib/typescript/src/index.d.ts +8 -0
- 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/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 +19 -3
- package/src/components/accordion/index.tsx +7 -5
- package/src/components/alert/index.tsx +3 -6
- package/src/components/bottom-sheet/index.tsx +82 -2
- package/src/components/button/index.tsx +52 -0
- package/src/components/empty-state/index.tsx +4 -5
- 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/message/index.tsx +312 -0
- package/src/components/scroll-fade/index.tsx +280 -0
- package/src/components/select/index.tsx +54 -9
- package/src/components/shimmer/index.tsx +228 -0
- package/src/components/surface/index.tsx +64 -0
- 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 +24 -0
- package/src/hooks/use-breakpoint.ts +52 -0
- package/src/hooks/use-copy-to-clipboard.ts +79 -0
- package/src/hooks/use-debounced-value.ts +23 -0
- package/src/hooks/use-disclosure.ts +57 -0
- package/src/hooks/use-keyboard-avoidance.ts +140 -0
- package/src/hooks/use-keyboard.ts +43 -0
- package/src/hooks/use-previous.ts +24 -0
- package/src/icons/index.tsx +4 -4
- package/src/index.ts +41 -0
- package/src/native/index.ts +79 -0
- package/src/primitives/keyboard-avoider.tsx +49 -0
- package/src/providers/panel-ui-provider.tsx +30 -9
- package/src/theme/use-theme.ts +1 -1
- package/theme.css +23 -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
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
+
});
|