panelui-native 0.28.1 → 0.32.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 (121) hide show
  1. package/README.md +22 -3
  2. package/lib/module/components/calendar/index.js +450 -132
  3. package/lib/module/components/calendar/index.js.map +1 -1
  4. package/lib/module/components/code-block/highlight.js +363 -0
  5. package/lib/module/components/code-block/highlight.js.map +1 -0
  6. package/lib/module/components/code-block/index.js +313 -0
  7. package/lib/module/components/code-block/index.js.map +1 -0
  8. package/lib/module/components/date-picker/index.js +8 -1
  9. package/lib/module/components/date-picker/index.js.map +1 -1
  10. package/lib/module/components/heatmap-chart/index.js +9 -8
  11. package/lib/module/components/heatmap-chart/index.js.map +1 -1
  12. package/lib/module/components/map/index.js +50 -4
  13. package/lib/module/components/map/index.js.map +1 -1
  14. package/lib/module/components/menu/index.js +714 -0
  15. package/lib/module/components/menu/index.js.map +1 -0
  16. package/lib/module/components/plan/index.js +582 -0
  17. package/lib/module/components/plan/index.js.map +1 -0
  18. package/lib/module/components/popover/index.js +35 -4
  19. package/lib/module/components/popover/index.js.map +1 -1
  20. package/lib/module/components/post/index.js +992 -0
  21. package/lib/module/components/post/index.js.map +1 -0
  22. package/lib/module/components/radio-group/index.js +13 -8
  23. package/lib/module/components/radio-group/index.js.map +1 -1
  24. package/lib/module/components/reasoning/index.js +244 -0
  25. package/lib/module/components/reasoning/index.js.map +1 -0
  26. package/lib/module/components/response/index.js +296 -0
  27. package/lib/module/components/response/index.js.map +1 -0
  28. package/lib/module/components/response/markdown.js +463 -0
  29. package/lib/module/components/response/markdown.js.map +1 -0
  30. package/lib/module/components/select/index.js +97 -15
  31. package/lib/module/components/select/index.js.map +1 -1
  32. package/lib/module/components/sources/index.js +221 -0
  33. package/lib/module/components/sources/index.js.map +1 -0
  34. package/lib/module/components/table/index.js +632 -0
  35. package/lib/module/components/table/index.js.map +1 -0
  36. package/lib/module/components/task/index.js +300 -0
  37. package/lib/module/components/task/index.js.map +1 -0
  38. package/lib/module/components/textarea/index.js +295 -0
  39. package/lib/module/components/textarea/index.js.map +1 -0
  40. package/lib/module/components/tooltip/index.js +179 -23
  41. package/lib/module/components/tooltip/index.js.map +1 -1
  42. package/lib/module/icons/index.js +339 -0
  43. package/lib/module/icons/index.js.map +1 -1
  44. package/lib/module/index.js +12 -1
  45. package/lib/module/index.js.map +1 -1
  46. package/lib/module/primitives/collapse.js +112 -0
  47. package/lib/module/primitives/collapse.js.map +1 -0
  48. package/lib/module/utils/date.js +129 -22
  49. package/lib/module/utils/date.js.map +1 -1
  50. package/lib/typescript/src/components/calendar/index.d.ts +79 -5
  51. package/lib/typescript/src/components/calendar/index.d.ts.map +1 -1
  52. package/lib/typescript/src/components/code-block/highlight.d.ts +39 -0
  53. package/lib/typescript/src/components/code-block/highlight.d.ts.map +1 -0
  54. package/lib/typescript/src/components/code-block/index.d.ts +113 -0
  55. package/lib/typescript/src/components/code-block/index.d.ts.map +1 -0
  56. package/lib/typescript/src/components/date-picker/index.d.ts +7 -1
  57. package/lib/typescript/src/components/date-picker/index.d.ts.map +1 -1
  58. package/lib/typescript/src/components/heatmap-chart/index.d.ts.map +1 -1
  59. package/lib/typescript/src/components/map/index.d.ts +8 -1
  60. package/lib/typescript/src/components/map/index.d.ts.map +1 -1
  61. package/lib/typescript/src/components/menu/index.d.ts +261 -0
  62. package/lib/typescript/src/components/menu/index.d.ts.map +1 -0
  63. package/lib/typescript/src/components/plan/index.d.ts +238 -0
  64. package/lib/typescript/src/components/plan/index.d.ts.map +1 -0
  65. package/lib/typescript/src/components/popover/index.d.ts +35 -1
  66. package/lib/typescript/src/components/popover/index.d.ts.map +1 -1
  67. package/lib/typescript/src/components/post/index.d.ts +358 -0
  68. package/lib/typescript/src/components/post/index.d.ts.map +1 -0
  69. package/lib/typescript/src/components/radio-group/index.d.ts.map +1 -1
  70. package/lib/typescript/src/components/reasoning/index.d.ts +97 -0
  71. package/lib/typescript/src/components/reasoning/index.d.ts.map +1 -0
  72. package/lib/typescript/src/components/response/index.d.ts +91 -0
  73. package/lib/typescript/src/components/response/index.d.ts.map +1 -0
  74. package/lib/typescript/src/components/response/markdown.d.ts +88 -0
  75. package/lib/typescript/src/components/response/markdown.d.ts.map +1 -0
  76. package/lib/typescript/src/components/select/index.d.ts +27 -2
  77. package/lib/typescript/src/components/select/index.d.ts.map +1 -1
  78. package/lib/typescript/src/components/sources/index.d.ts +85 -0
  79. package/lib/typescript/src/components/sources/index.d.ts.map +1 -0
  80. package/lib/typescript/src/components/table/index.d.ts +274 -0
  81. package/lib/typescript/src/components/table/index.d.ts.map +1 -0
  82. package/lib/typescript/src/components/task/index.d.ts +103 -0
  83. package/lib/typescript/src/components/task/index.d.ts.map +1 -0
  84. package/lib/typescript/src/components/textarea/index.d.ts +175 -0
  85. package/lib/typescript/src/components/textarea/index.d.ts.map +1 -0
  86. package/lib/typescript/src/components/tooltip/index.d.ts +63 -8
  87. package/lib/typescript/src/components/tooltip/index.d.ts.map +1 -1
  88. package/lib/typescript/src/icons/index.d.ts +54 -1
  89. package/lib/typescript/src/icons/index.d.ts.map +1 -1
  90. package/lib/typescript/src/index.d.ts +14 -3
  91. package/lib/typescript/src/index.d.ts.map +1 -1
  92. package/lib/typescript/src/primitives/collapse.d.ts +42 -0
  93. package/lib/typescript/src/primitives/collapse.d.ts.map +1 -0
  94. package/lib/typescript/src/utils/date.d.ts +49 -5
  95. package/lib/typescript/src/utils/date.d.ts.map +1 -1
  96. package/package.json +1 -1
  97. package/src/components/calendar/index.tsx +519 -139
  98. package/src/components/code-block/highlight.ts +278 -0
  99. package/src/components/code-block/index.tsx +341 -0
  100. package/src/components/date-picker/index.tsx +19 -1
  101. package/src/components/heatmap-chart/index.tsx +9 -9
  102. package/src/components/map/index.tsx +53 -4
  103. package/src/components/menu/index.tsx +812 -0
  104. package/src/components/plan/index.tsx +648 -0
  105. package/src/components/popover/index.tsx +69 -2
  106. package/src/components/post/index.tsx +1116 -0
  107. package/src/components/radio-group/index.tsx +11 -2
  108. package/src/components/reasoning/index.tsx +297 -0
  109. package/src/components/response/index.tsx +390 -0
  110. package/src/components/response/markdown.ts +489 -0
  111. package/src/components/select/index.tsx +132 -12
  112. package/src/components/sources/index.tsx +278 -0
  113. package/src/components/table/index.tsx +747 -0
  114. package/src/components/task/index.tsx +329 -0
  115. package/src/components/textarea/index.tsx +381 -0
  116. package/src/components/tooltip/index.tsx +203 -20
  117. package/src/icons/index.tsx +264 -1
  118. package/src/index.ts +131 -0
  119. package/src/primitives/collapse.tsx +128 -0
  120. package/src/utils/date.ts +146 -22
  121. package/theme.css +112 -0
@@ -0,0 +1,329 @@
1
+ /**
2
+ * Task — one step an agent took, and what it did while it was there.
3
+ *
4
+ * The header is the step; the body is the detail nobody reads unless something
5
+ * went wrong. Open by default, because a task that is running is the thing the
6
+ * reader is watching — and it stays open afterwards rather than folding itself
7
+ * away, because unlike a reasoning trace the steps are the record of what
8
+ * happened and are worth scrolling back through.
9
+ *
10
+ * ```tsx
11
+ * <Task status="running">
12
+ * <Task.Trigger title="Searching the codebase" />
13
+ * <Task.Content>
14
+ * <Task.Item>
15
+ * Read <Task.File>calendar/index.tsx</Task.File>
16
+ * </Task.Item>
17
+ * </Task.Content>
18
+ * </Task>
19
+ * ```
20
+ *
21
+ * ## Where the props come from
22
+ *
23
+ * With the AI SDK a task is a tool-call part: `part.type` is `tool-<name>` and
24
+ * `part.state` runs `input-streaming` → `input-available` → `output-available`
25
+ * or `output-error`. Those map onto `status` as pending, running, complete and
26
+ * error; `part.input` is usually what the body should say.
27
+ */
28
+ import {
29
+ createContext,
30
+ useCallback,
31
+ useContext,
32
+ useEffect,
33
+ useMemo,
34
+ useState,
35
+ type ReactNode,
36
+ } from 'react';
37
+ import { Pressable, View, type PressableProps, type ViewProps } from 'react-native';
38
+ import Animated, {
39
+ useAnimatedStyle,
40
+ useReducedMotion,
41
+ useSharedValue,
42
+ withTiming,
43
+ } from 'react-native-reanimated';
44
+ import { tv } from 'tailwind-variants';
45
+ import { useCSSVariable } from 'uniwind';
46
+ import {
47
+ AlertTriangleIcon,
48
+ CheckCircleIcon,
49
+ ChevronDownIcon,
50
+ CircleIcon,
51
+ SearchIcon,
52
+ } from '../../icons';
53
+ import { Collapse } from '../../primitives/collapse';
54
+ import { Text, textChildren } from '../../primitives/text';
55
+ import { cn } from '../../utils/cn';
56
+ import { Shimmer } from '../shimmer';
57
+
58
+ export type TaskStatus = 'pending' | 'running' | 'complete' | 'error';
59
+
60
+ const taskVariants = tv({
61
+ slots: {
62
+ root: 'w-full gap-2',
63
+ trigger: 'flex-row items-center gap-2 py-0.5',
64
+ title: 'flex-1 text-sm text-muted-foreground',
65
+ /*
66
+ * Indented behind a rule rather than merely padded. The rule is what says
67
+ * these lines belong to the step above them instead of being the next few
68
+ * steps, which matters as soon as there is more than one task in a row.
69
+ */
70
+ content: 'ms-2 gap-1.5 border-s border-border ps-4',
71
+ item: 'text-sm leading-relaxed text-muted-foreground',
72
+ file: 'flex-row items-center gap-1 self-start rounded-md border border-border bg-muted px-1.5 py-0.5',
73
+ fileLabel: 'font-mono text-xs text-foreground',
74
+ },
75
+ variants: {
76
+ status: {
77
+ pending: { title: 'text-muted-foreground/60' },
78
+ running: {},
79
+ complete: {},
80
+ error: { title: 'text-destructive' },
81
+ },
82
+ },
83
+ defaultVariants: {
84
+ status: 'complete',
85
+ },
86
+ });
87
+
88
+ interface TaskContextValue {
89
+ status: TaskStatus;
90
+ open: boolean;
91
+ setOpen: (open: boolean) => void;
92
+ }
93
+
94
+ const TaskContext = createContext<TaskContextValue | null>(null);
95
+
96
+ function useTask(component: string): TaskContextValue {
97
+ const context = useContext(TaskContext);
98
+ if (!context) {
99
+ throw new Error(`${component} must be used within a <Task>`);
100
+ }
101
+ return context;
102
+ }
103
+
104
+ export interface TaskProps extends Omit<ViewProps, 'children'> {
105
+ className?: string;
106
+ /**
107
+ * Where the step has got to. Drives the leading glyph, and puts a shimmer on
108
+ * the title while it is `running`.
109
+ */
110
+ status?: TaskStatus;
111
+ /** Controlled open state. */
112
+ open?: boolean;
113
+ /** Initial state when uncontrolled. Open — the steps are the record. */
114
+ defaultOpen?: boolean;
115
+ onOpenChange?: (open: boolean) => void;
116
+ children?: ReactNode;
117
+ }
118
+
119
+ function TaskRoot({
120
+ className,
121
+ status = 'complete',
122
+ open: openProp,
123
+ defaultOpen = true,
124
+ onOpenChange,
125
+ children,
126
+ ...props
127
+ }: TaskProps) {
128
+ const { root } = taskVariants({ status });
129
+ const [internalOpen, setInternalOpen] = useState(defaultOpen);
130
+ const isControlled = openProp !== undefined;
131
+ const open = isControlled ? openProp : internalOpen;
132
+
133
+ const setOpen = useCallback(
134
+ (next: boolean) => {
135
+ if (!isControlled) setInternalOpen(next);
136
+ onOpenChange?.(next);
137
+ },
138
+ [isControlled, onOpenChange]
139
+ );
140
+
141
+ const context = useMemo(() => ({ status, open, setOpen }), [status, open, setOpen]);
142
+
143
+ return (
144
+ <TaskContext.Provider value={context}>
145
+ <View {...props} className={cn(root(), className)}>
146
+ {children}
147
+ </View>
148
+ </TaskContext.Provider>
149
+ );
150
+ }
151
+ TaskRoot.displayName = 'Task';
152
+
153
+ export interface TaskTriggerProps extends Omit<PressableProps, 'children' | 'style'> {
154
+ className?: string;
155
+ /** What the step is. Shimmers while the status is `running`. */
156
+ title?: string;
157
+ /** Leading glyph. Derived from `status` when not given. */
158
+ icon?: ReactNode;
159
+ /** Replaces the whole row. */
160
+ children?: ReactNode;
161
+ }
162
+
163
+ function TaskTrigger({
164
+ className,
165
+ title,
166
+ icon,
167
+ children,
168
+ onPress,
169
+ ...props
170
+ }: TaskTriggerProps) {
171
+ const { status, open, setOpen } = useTask('Task.Trigger');
172
+ const slots = taskVariants({ status });
173
+ const reducedMotion = useReducedMotion();
174
+ const progress = useSharedValue(open ? 1 : 0);
175
+
176
+ useEffect(() => {
177
+ progress.value = reducedMotion
178
+ ? open
179
+ ? 1
180
+ : 0
181
+ : withTiming(open ? 1 : 0, { duration: 180 });
182
+ }, [open, reducedMotion, progress]);
183
+
184
+ const chevronStyle = useAnimatedStyle(() => ({
185
+ transform: [{ rotate: `${progress.value * 180}deg` }],
186
+ }));
187
+
188
+ return (
189
+ <Pressable
190
+ accessibilityRole="button"
191
+ accessibilityState={{ expanded: open }}
192
+ onPress={(event) => {
193
+ onPress?.(event);
194
+ setOpen(!open);
195
+ }}
196
+ className={cn(slots.trigger(), className)}
197
+ {...props}
198
+ >
199
+ {children ?? (
200
+ <>
201
+ {icon ?? <TaskStatusIcon status={status} />}
202
+ {status === 'running' && title ? (
203
+ <View className="flex-1">
204
+ <Shimmer textClassName={slots.title()}>{title}</Shimmer>
205
+ </View>
206
+ ) : (
207
+ <Text numberOfLines={1} className={slots.title()}>
208
+ {title}
209
+ </Text>
210
+ )}
211
+ <Animated.View style={chevronStyle}>
212
+ <ChevronDownIcon size={16} />
213
+ </Animated.View>
214
+ </>
215
+ )}
216
+ </Pressable>
217
+ );
218
+ }
219
+
220
+ /**
221
+ * The glyph for each status. A magnifier while running: a step is a search.
222
+ *
223
+ * Every one is given an explicit colour. The filled status icons fall back to
224
+ * `currentColor`, which React Native's SVG does not resolve — it paints black,
225
+ * which on a dark theme is a black disc floating in the row. They are built to
226
+ * take their colour from an enclosing `IconColorProvider`, and a task row is
227
+ * not one, so the tint is resolved here.
228
+ */
229
+ function TaskStatusIcon({ status }: { status: TaskStatus }) {
230
+ const muted = useTint('--color-muted-foreground');
231
+ const success = useTint('--color-success');
232
+ const destructive = useTint('--color-destructive');
233
+
234
+ switch (status) {
235
+ case 'pending':
236
+ return <CircleIcon size={16} color={muted} />;
237
+ case 'error':
238
+ return <AlertTriangleIcon size={16} color={destructive} />;
239
+ case 'complete':
240
+ return <CheckCircleIcon size={16} color={success} />;
241
+ default:
242
+ return <SearchIcon size={16} color={muted} />;
243
+ }
244
+ }
245
+
246
+ /**
247
+ * A theme token as a colour an icon will accept.
248
+ *
249
+ * `useCSSVariable` answers with whatever the token holds, which for a length or
250
+ * a number is not a colour at all — so anything that is not a string is dropped
251
+ * and the icon falls back to what it would have used anyway.
252
+ */
253
+ function useTint(variable: string): string | undefined {
254
+ const raw = useCSSVariable(variable);
255
+ return typeof raw === 'string' ? raw : undefined;
256
+ }
257
+
258
+ export interface TaskContentProps extends Omit<ViewProps, 'children'> {
259
+ className?: string;
260
+ children?: ReactNode;
261
+ }
262
+
263
+ function TaskContent({ className, children, ...props }: TaskContentProps) {
264
+ const { open } = useTask('Task.Content');
265
+ const { content } = taskVariants();
266
+
267
+ return (
268
+ <Collapse open={open} className={cn(content(), className)} {...props}>
269
+ {children}
270
+ </Collapse>
271
+ );
272
+ }
273
+
274
+ export interface TaskItemProps extends Omit<ViewProps, 'children'> {
275
+ className?: string;
276
+ children?: ReactNode;
277
+ }
278
+
279
+ /** One line of what the step did. */
280
+ function TaskItem({ className, children, ...props }: TaskItemProps) {
281
+ const { item } = taskVariants();
282
+
283
+ return (
284
+ <View {...props} className={cn('flex-row flex-wrap items-center gap-1', className)}>
285
+ {textChildren(children, (text) => (
286
+ <Text className={item()}>{text}</Text>
287
+ ))}
288
+ </View>
289
+ );
290
+ }
291
+
292
+ export interface TaskFileProps extends Omit<ViewProps, 'children'> {
293
+ className?: string;
294
+ /** A glyph for the file's kind, drawn before the name. */
295
+ icon?: ReactNode;
296
+ children?: ReactNode;
297
+ }
298
+
299
+ /**
300
+ * A filename inside a line, drawn as a chip.
301
+ *
302
+ * Bordered rather than merely monospaced, because a path in the middle of a
303
+ * sentence is otherwise indistinguishable from the sentence — and the paths
304
+ * are the part of a task line anyone actually scans for.
305
+ */
306
+ function TaskFile({ className, icon, children, ...props }: TaskFileProps) {
307
+ const { file, fileLabel } = taskVariants();
308
+
309
+ return (
310
+ <View {...props} className={cn(file(), className)}>
311
+ {icon}
312
+ {textChildren(children, (text) => (
313
+ <Text className={fileLabel()}>{text}</Text>
314
+ ))}
315
+ </View>
316
+ );
317
+ }
318
+
319
+ TaskTrigger.displayName = 'Task.Trigger';
320
+ TaskContent.displayName = 'Task.Content';
321
+ TaskItem.displayName = 'Task.Item';
322
+ TaskFile.displayName = 'Task.File';
323
+
324
+ export const Task = Object.assign(TaskRoot, {
325
+ Trigger: TaskTrigger,
326
+ Content: TaskContent,
327
+ Item: TaskItem,
328
+ File: TaskFile,
329
+ });
@@ -0,0 +1,381 @@
1
+ /**
2
+ * Textarea — a field for text that runs to several lines.
3
+ *
4
+ * It shares Input's focus treatment: the border crosses between two colours on
5
+ * the UI thread over 150ms rather than snapping, because a border that switches
6
+ * wholesale reads as a redraw instead of as the field answering you.
7
+ *
8
+ * Height comes from `rows` rather than from a class, so it composes with `size`
9
+ * instead of fighting it — three rows of 14px text and three rows of 16px text
10
+ * are different heights, and both are three rows. `autoGrow` then lets the
11
+ * field follow its content up to `maxRows`, after which it scrolls, which is
12
+ * the behaviour a composer wants: it starts small, opens up as you write, and
13
+ * stops before it eats the screen.
14
+ *
15
+ * ```tsx
16
+ * <Textarea label="Notes" placeholder="Anything we should know?" />
17
+ * <Textarea autoGrow rows={2} maxRows={8} showCount maxLength={280} />
18
+ * ```
19
+ *
20
+ * For a single line, reach for `Input`.
21
+ */
22
+ import { forwardRef, useCallback, useEffect, useMemo, useState } from 'react';
23
+ import {
24
+ TextInput,
25
+ View,
26
+ type NativeSyntheticEvent,
27
+ type TextInputContentSizeChangeEventData,
28
+ type TextInputProps,
29
+ } from 'react-native';
30
+ import Animated, {
31
+ interpolateColor,
32
+ useAnimatedStyle,
33
+ useSharedValue,
34
+ withTiming,
35
+ } from 'react-native-reanimated';
36
+ import { tv, type VariantProps } from 'tailwind-variants';
37
+ import { useCSSVariable } from 'uniwind';
38
+ import type { KeyboardAvoidanceMode } from '../../hooks/use-keyboard-avoidance';
39
+ import { KeyboardAvoider } from '../../primitives/keyboard-avoider';
40
+ import { Text } from '../../primitives/text';
41
+ import { Label } from '../label';
42
+
43
+ /** Long enough to read as a transition, short enough not to lag a fast tab. */
44
+ const FOCUS_DURATION = 150;
45
+
46
+ /**
47
+ * Type size and the leading it is given, per `size`.
48
+ *
49
+ * The height of the box is derived from these rather than set as a class,
50
+ * because `rows` is a count and a count only becomes a height once you know how
51
+ * tall a line is. They are the same numbers the classes below carry; keeping
52
+ * them here as well is what lets `rows` and `size` compose.
53
+ */
54
+ const METRICS = {
55
+ sm: { fontSize: 14, lineHeight: 20, padding: 10 },
56
+ md: { fontSize: 16, lineHeight: 22, padding: 12 },
57
+ lg: { fontSize: 16, lineHeight: 24, padding: 14 },
58
+ } as const;
59
+
60
+ const textareaVariants = tv({
61
+ slots: {
62
+ container: 'w-full gap-1.5',
63
+ /*
64
+ * No height here: it is computed from `rows` and applied as a style. Text
65
+ * starts at the top rather than floating in the middle of an empty box, and
66
+ * the leading is wanted — between several lines it separates them, where in
67
+ * a one-line field it would push the text off centre.
68
+ */
69
+ field: 'w-full rounded-lg border text-foreground',
70
+ footer: 'w-full flex-row items-start justify-between gap-3',
71
+ description: 'text-sm text-muted-foreground',
72
+ error: 'text-sm text-destructive',
73
+ count: 'shrink-0 text-sm tabular-nums text-muted-foreground',
74
+ },
75
+ variants: {
76
+ variant: {
77
+ // The border colour is animated, so it is deliberately not set here —
78
+ // only the background belongs to the class.
79
+ outline: { field: 'bg-background' },
80
+ filled: { field: 'bg-muted' },
81
+ },
82
+ size: {
83
+ sm: { field: 'px-3 text-[14px]' },
84
+ md: { field: 'px-3.5 text-[16px]' },
85
+ lg: { field: 'px-4 text-[16px]' },
86
+ },
87
+ disabled: {
88
+ true: { field: 'opacity-[0.64]' },
89
+ },
90
+ /** Past the limit the counter is the error, so it is tinted like one. */
91
+ over: {
92
+ true: { count: 'text-destructive' },
93
+ },
94
+ },
95
+ defaultVariants: {
96
+ variant: 'outline',
97
+ size: 'md',
98
+ },
99
+ });
100
+
101
+ type TextareaVariantProps = VariantProps<typeof textareaVariants>;
102
+
103
+ export interface TextareaProps
104
+ extends Omit<TextInputProps, 'multiline' | 'numberOfLines'>,
105
+ Omit<TextareaVariantProps, 'disabled' | 'over'> {
106
+ className?: string;
107
+ containerClassName?: string;
108
+ label?: string;
109
+ description?: string;
110
+ /** Error message. When set, the field renders in its invalid state. */
111
+ errorMessage?: string;
112
+ /** Marks the field required — an asterisk on the label, and the a11y state. */
113
+ isRequired?: boolean;
114
+ disabled?: boolean;
115
+ /** How many lines of text the field is tall before it scrolls or grows. */
116
+ rows?: number;
117
+ /**
118
+ * Grow with the text, one line at a time, up to `maxRows` — after which the
119
+ * field holds its height and scrolls instead. Without it the field stays at
120
+ * `rows` and scrolls from the first line past it.
121
+ */
122
+ autoGrow?: boolean;
123
+ /** The tallest `autoGrow` will go. Ignored without it. */
124
+ maxRows?: number;
125
+ /**
126
+ * Show how much of `maxLength` is used, under the field. Needs `maxLength`;
127
+ * a counter with no limit to count towards says nothing.
128
+ */
129
+ showCount?: boolean;
130
+ /**
131
+ * Keep the field clear of the software keyboard. Moves by exactly the
132
+ * overlap, and not at all when the field is already clear — or when the
133
+ * keyboard belongs to a different field. The overlap is re-read every frame
134
+ * while the field is focused, so the field keeps its place in the page as it
135
+ * scrolls under and back out of the keyboard.
136
+ *
137
+ * Install `react-native-keyboard-controller` for this to behave on Android.
138
+ *
139
+ * Do not toggle this at runtime — it changes which component renders the
140
+ * container, which would remount the field and drop focus.
141
+ */
142
+ avoidKeyboard?: boolean;
143
+ /**
144
+ * How the field gets clear. `lift` moves it up by its overlap and follows
145
+ * the scroll — right for a field in the flow of a page. `dock` makes it
146
+ * travel with the keyboard, for a composer already pinned near the bottom
147
+ * edge; pair it with `keyboardBottomInset`.
148
+ */
149
+ keyboardMode?: KeyboardAvoidanceMode;
150
+ /** Gap kept between the field and the keyboard. `keyboardMode="lift"` only. */
151
+ keyboardOffset?: number;
152
+ /**
153
+ * How far above the bottom edge the field already sits — usually the safe
154
+ * area inset. `keyboardMode="dock"` only.
155
+ */
156
+ keyboardBottomInset?: number;
157
+ }
158
+
159
+ const AnimatedTextInput = Animated.createAnimatedComponent(TextInput);
160
+
161
+ export const Textarea = forwardRef<TextInput, TextareaProps>(
162
+ (
163
+ {
164
+ className,
165
+ containerClassName,
166
+ label,
167
+ description,
168
+ errorMessage,
169
+ isRequired,
170
+ disabled,
171
+ variant,
172
+ size = 'md',
173
+ rows = 4,
174
+ autoGrow = false,
175
+ maxRows = 10,
176
+ showCount = false,
177
+ avoidKeyboard = false,
178
+ keyboardMode = 'lift',
179
+ keyboardOffset = 16,
180
+ keyboardBottomInset = 0,
181
+ onFocus,
182
+ onBlur,
183
+ onContentSizeChange,
184
+ style,
185
+ ...props
186
+ },
187
+ ref
188
+ ) => {
189
+ const [focused, setFocused] = useState(false);
190
+ const invalid = !!errorMessage;
191
+
192
+ const metrics = METRICS[size];
193
+ /* Both paddings plus the lines themselves — the box a row count asks for. */
194
+ const heightFor = useCallback(
195
+ (lines: number) => lines * metrics.lineHeight + metrics.padding * 2,
196
+ [metrics]
197
+ );
198
+
199
+ const minHeight = heightFor(rows);
200
+ const maxHeight = autoGrow ? heightFor(Math.max(rows, maxRows)) : undefined;
201
+
202
+ /*
203
+ * What the text actually measures, once it has been laid out. It only ever
204
+ * raises the floor — the field never shrinks below `rows`, and never grows
205
+ * past `maxRows`, so the clamping happens here rather than in the style.
206
+ */
207
+ const [contentHeight, setContentHeight] = useState(0);
208
+ const grownHeight = autoGrow
209
+ ? Math.min(
210
+ Math.max(minHeight, contentHeight + metrics.padding * 2),
211
+ maxHeight ?? Number.POSITIVE_INFINITY
212
+ )
213
+ : minHeight;
214
+
215
+ const length = props.value?.length ?? props.defaultValue?.length ?? 0;
216
+ const over = !!props.maxLength && length >= props.maxLength;
217
+
218
+ const placeholderColor = useCSSVariable('--color-muted-foreground');
219
+ const restColor = useCSSVariable('--color-input');
220
+ const focusColor = useCSSVariable('--color-ring');
221
+ const errorColor = useCSSVariable('--color-destructive');
222
+
223
+ const slots = textareaVariants({
224
+ variant,
225
+ size,
226
+ disabled: !!disabled,
227
+ over,
228
+ });
229
+
230
+ /*
231
+ * Border colour is driven by one 0..1 value rather than by a class per
232
+ * state. Uniwind can only swap a class wholesale, which is the snap this
233
+ * is here to avoid, and a shared value crosses between the two colours on
234
+ * the UI thread without a re-render.
235
+ */
236
+ const focus = useSharedValue(0);
237
+ useEffect(() => {
238
+ focus.value = withTiming(focused ? 1 : 0, { duration: FOCUS_DURATION });
239
+ }, [focused, focus]);
240
+
241
+ const resting = typeof restColor === 'string' ? restColor : '#e5e5e5';
242
+ const active = invalid
243
+ ? typeof errorColor === 'string'
244
+ ? errorColor
245
+ : '#ef4444'
246
+ : typeof focusColor === 'string'
247
+ ? focusColor
248
+ : '#a3a3a3';
249
+ // An invalid field is tinted even at rest — the error is a fact about the
250
+ // value, not about whether the field happens to be focused.
251
+ const idle = invalid ? active : resting;
252
+
253
+ const borderStyle = useAnimatedStyle(() => ({
254
+ borderColor: interpolateColor(focus.value, [0, 1], [idle, active]),
255
+ }));
256
+
257
+ const handleFocus = useCallback<NonNullable<TextInputProps['onFocus']>>(
258
+ (event) => {
259
+ setFocused(true);
260
+ onFocus?.(event);
261
+ },
262
+ [onFocus]
263
+ );
264
+
265
+ const handleBlur = useCallback<NonNullable<TextInputProps['onBlur']>>(
266
+ (event) => {
267
+ setFocused(false);
268
+ onBlur?.(event);
269
+ },
270
+ [onBlur]
271
+ );
272
+
273
+ const handleContentSizeChange = useCallback(
274
+ (event: NativeSyntheticEvent<TextInputContentSizeChangeEventData>) => {
275
+ if (autoGrow) {
276
+ setContentHeight(event.nativeEvent.contentSize.height);
277
+ }
278
+ onContentSizeChange?.(event);
279
+ },
280
+ [autoGrow, onContentSizeChange]
281
+ );
282
+
283
+ /*
284
+ * Type metrics go through `style` rather than through classes because the
285
+ * height is computed from them, and a line height set in two places is a
286
+ * line height that will disagree with itself.
287
+ */
288
+ const boxStyle = useMemo(
289
+ () => ({
290
+ height: grownHeight,
291
+ lineHeight: metrics.lineHeight,
292
+ paddingTop: metrics.padding,
293
+ paddingBottom: metrics.padding,
294
+ // Android starts the caret in the middle of the box without it.
295
+ textAlignVertical: 'top' as const,
296
+ }),
297
+ [grownHeight, metrics]
298
+ );
299
+
300
+ const footer =
301
+ errorMessage || description || (showCount && props.maxLength) ? (
302
+ <View className={slots.footer()}>
303
+ {errorMessage ? (
304
+ <Text className={slots.error()}>{errorMessage}</Text>
305
+ ) : description ? (
306
+ <Text className={slots.description()}>{description}</Text>
307
+ ) : (
308
+ <View className="flex-1" />
309
+ )}
310
+ {showCount && props.maxLength ? (
311
+ <Text className={slots.count()}>
312
+ {length}/{props.maxLength}
313
+ </Text>
314
+ ) : null}
315
+ </View>
316
+ ) : null;
317
+
318
+ const body = (
319
+ <>
320
+ {label ? (
321
+ <Label isRequired={isRequired} isInvalid={invalid} isDisabled={!!disabled}>
322
+ {label}
323
+ </Label>
324
+ ) : null}
325
+ <AnimatedTextInput
326
+ ref={ref}
327
+ multiline
328
+ editable={!disabled}
329
+ onFocus={handleFocus}
330
+ onBlur={handleBlur}
331
+ onContentSizeChange={handleContentSizeChange}
332
+ accessibilityLabel={label}
333
+ accessibilityState={{ disabled: !!disabled }}
334
+ aria-required={isRequired}
335
+ aria-invalid={invalid}
336
+ className={slots.field({ className })}
337
+ // Caller styles come last, but never last enough to drop the
338
+ // animated border or the height the row count asked for.
339
+ style={[boxStyle, borderStyle, style]}
340
+ placeholderTextColor={
341
+ typeof placeholderColor === 'string' ? placeholderColor : undefined
342
+ }
343
+ {...props}
344
+ />
345
+ {footer}
346
+ </>
347
+ );
348
+
349
+ const containerClasses = slots.container({ className: containerClassName });
350
+
351
+ /*
352
+ * The keyboard hook is deliberately behind a component boundary rather
353
+ * than an `enabled` flag. Calling it at all has global consequences —
354
+ * without the keyboard controller installed it falls back to Reanimated's
355
+ * useAnimatedKeyboard, which switches Android out of adjustResize for the
356
+ * whole app. A field that never asked to avoid the keyboard must not do
357
+ * that to every other screen.
358
+ */
359
+ if (avoidKeyboard) {
360
+ return (
361
+ <KeyboardAvoider
362
+ // Only while *this* field is the one being typed into. Without it
363
+ // every avoiding field on the screen lifts the moment any field
364
+ // anywhere is tapped, and since they all aim at the same gap above
365
+ // the keyboard, they arrive stacked on top of one another.
366
+ active={focused}
367
+ mode={keyboardMode}
368
+ offset={keyboardOffset}
369
+ bottomInset={keyboardBottomInset}
370
+ className={containerClasses}
371
+ >
372
+ {body}
373
+ </KeyboardAvoider>
374
+ );
375
+ }
376
+
377
+ return <View className={containerClasses}>{body}</View>;
378
+ }
379
+ );
380
+
381
+ Textarea.displayName = 'Textarea';