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,648 @@
1
+ /**
2
+ * Plan — what an agent intends to do, before it does it.
3
+ *
4
+ * A card rather than a run of rows, because a plan is a thing the reader is
5
+ * being asked to approve: it needs an edge around it so that where it starts
6
+ * and stops is not a matter of interpretation, and a footer that can hold the
7
+ * button that approves it.
8
+ *
9
+ * The body is a rail of steps rather than prose, because a plan is an ordered
10
+ * list of things that will happen and the reader's question is always *which
11
+ * one is running*. A paragraph describing the same four steps cannot be glanced
12
+ * at to answer that; a column of markers can. The rail fills behind the steps
13
+ * that are done, so progress is legible from its left edge alone, and
14
+ * `Plan.Steps` counts itself so `Plan.Progress` can say `2 of 4` up in the
15
+ * header without the total being stated twice.
16
+ *
17
+ * It streams. `isStreaming` puts a shimmer on the title, the description and
18
+ * the one step that is running, which is the honest way to render text that is
19
+ * still arriving — the alternative is a title that grows a word at a time and
20
+ * reads as finished at every intermediate length.
21
+ *
22
+ * ```tsx
23
+ * <Plan isStreaming={streaming}>
24
+ * <Plan.Header>
25
+ * <Plan.Icon><FileIcon size={16} /></Plan.Icon>
26
+ * <Plan.Title>Migrate the calendar</Plan.Title>
27
+ * <Plan.Description>Four files, no API change.</Plan.Description>
28
+ * <Plan.Action>
29
+ * <Plan.Progress />
30
+ * <Plan.Trigger />
31
+ * </Plan.Action>
32
+ * </Plan.Header>
33
+ * <Plan.Content>
34
+ * <Plan.Steps>
35
+ * <Plan.Step status="done" meta="utils/date.ts">Read the date utils</Plan.Step>
36
+ * <Plan.Step status="active">Replace the month grid</Plan.Step>
37
+ * <Plan.Step>Update the docs page</Plan.Step>
38
+ * </Plan.Steps>
39
+ * </Plan.Content>
40
+ * <Plan.Footer>
41
+ * <Button variant="outline">Revise</Button>
42
+ * <Button>Approve</Button>
43
+ * </Plan.Footer>
44
+ * </Plan>
45
+ * ```
46
+ *
47
+ * ## Where the props come from
48
+ *
49
+ * With the AI SDK a plan is usually an `experimental_useObject` stream, where
50
+ * `isStreaming` is the hook's `isLoading` and the fields arrive one at a time —
51
+ * which is exactly the case the shimmer exists for, since a partial object has
52
+ * a title before it has anything else.
53
+ */
54
+ import {
55
+ Children,
56
+ createContext,
57
+ isValidElement,
58
+ useCallback,
59
+ useContext,
60
+ useEffect,
61
+ useMemo,
62
+ useState,
63
+ type ReactElement,
64
+ type ReactNode,
65
+ } from 'react';
66
+ import { Pressable, View, type PressableProps, type ViewProps } from 'react-native';
67
+ import Animated, {
68
+ useAnimatedStyle,
69
+ useReducedMotion,
70
+ useSharedValue,
71
+ withTiming,
72
+ } from 'react-native-reanimated';
73
+ import { tv } from 'tailwind-variants';
74
+ import { useCSSVariable } from 'uniwind';
75
+ import { CheckIcon, ChevronDownIcon } from '../../icons';
76
+ import { Collapse } from '../../primitives/collapse';
77
+ import { Text, textChildren, type TextProps } from '../../primitives/text';
78
+ import { cn } from '../../utils/cn';
79
+ import { Shimmer } from '../shimmer';
80
+
81
+ const planVariants = tv({
82
+ slots: {
83
+ root: 'w-full overflow-hidden rounded-2xl border border-border bg-card shadow-sm',
84
+ header: 'flex-row items-start gap-3 p-4',
85
+ icon: 'h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-muted',
86
+ heading: 'min-w-0 flex-1 gap-1',
87
+ title: 'text-base font-semibold text-card-foreground',
88
+ description: 'text-sm text-muted-foreground',
89
+ action: 'shrink-0 flex-row items-center gap-1',
90
+ progress: 'text-xs font-medium text-muted-foreground',
91
+ trigger: 'h-8 w-8 items-center justify-center rounded-full active:bg-accent',
92
+ content: 'gap-2 px-4 pb-4',
93
+ steps: 'w-full',
94
+ step: 'w-full flex-row gap-3',
95
+ stepRail: 'w-4 items-center pt-px',
96
+ stepMarker: 'h-4 w-4 items-center justify-center rounded-full',
97
+ stepConnector: 'w-px flex-1 rounded-full',
98
+ stepBody: 'min-w-0 flex-1 gap-0.5 pb-3',
99
+ stepTitle: 'text-sm',
100
+ stepDescription: 'text-xs text-muted-foreground',
101
+ stepMeta:
102
+ 'self-start rounded-md bg-muted px-1.5 py-0.5 font-mono text-[11px] text-muted-foreground',
103
+ footer: 'flex-row items-center gap-2 border-t border-border p-4',
104
+ },
105
+ });
106
+
107
+ export type PlanStepStatus = 'pending' | 'active' | 'done' | 'skipped';
108
+
109
+ /**
110
+ * What each state does to a step's marker, its title and the rail below it.
111
+ *
112
+ * A lookup rather than a `tv()` variant because it belongs to `Plan.Step`, not
113
+ * to `Plan` — a variant here would be read off the root and documented as
114
+ * something you could pass to the card.
115
+ *
116
+ * The connector is filled behind a step that is finished, which is what turns
117
+ * the left-hand column into a progress bar stood on its end: how far the plan
118
+ * has got is readable from the rail alone, without counting markers.
119
+ */
120
+ const stepStatus: Record<
121
+ PlanStepStatus,
122
+ { marker: string; title: string; connector: string }
123
+ > = {
124
+ pending: {
125
+ marker: 'border border-border',
126
+ title: 'text-muted-foreground',
127
+ connector: 'bg-border',
128
+ },
129
+ active: {
130
+ marker: 'border-2 border-primary',
131
+ title: 'font-medium text-foreground',
132
+ connector: 'bg-border',
133
+ },
134
+ done: {
135
+ marker: 'bg-primary',
136
+ title: 'text-foreground',
137
+ connector: 'bg-primary',
138
+ },
139
+ skipped: {
140
+ marker: 'border border-dashed border-border',
141
+ title: 'text-muted-foreground line-through',
142
+ connector: 'bg-border',
143
+ },
144
+ };
145
+
146
+ /** How far down the rail the plan has got. Reported by `Plan.Steps`. */
147
+ export interface PlanStepCounts {
148
+ done: number;
149
+ total: number;
150
+ }
151
+
152
+ interface PlanContextValue {
153
+ isStreaming: boolean;
154
+ open: boolean;
155
+ setOpen: (open: boolean) => void;
156
+ counts: PlanStepCounts | null;
157
+ reportCounts: (counts: PlanStepCounts) => void;
158
+ }
159
+
160
+ const PlanContext = createContext<PlanContextValue | null>(null);
161
+
162
+ function usePlan(component: string): PlanContextValue {
163
+ const context = useContext(PlanContext);
164
+ if (!context) {
165
+ throw new Error(`${component} must be used within a <Plan>`);
166
+ }
167
+ return context;
168
+ }
169
+
170
+ export interface PlanProps extends Omit<ViewProps, 'children'> {
171
+ className?: string;
172
+ /** Whether the plan is still being written. Shimmers the title and description. */
173
+ isStreaming?: boolean;
174
+ /** Controlled open state of the body. */
175
+ open?: boolean;
176
+ /** Initial state when uncontrolled. */
177
+ defaultOpen?: boolean;
178
+ onOpenChange?: (open: boolean) => void;
179
+ children?: ReactNode;
180
+ }
181
+
182
+ function PlanRoot({
183
+ className,
184
+ isStreaming = false,
185
+ open: openProp,
186
+ defaultOpen = true,
187
+ onOpenChange,
188
+ children,
189
+ ...props
190
+ }: PlanProps) {
191
+ const { root } = planVariants();
192
+ const [internalOpen, setInternalOpen] = useState(defaultOpen);
193
+ const isControlled = openProp !== undefined;
194
+ const open = isControlled ? openProp : internalOpen;
195
+
196
+ const setOpen = useCallback(
197
+ (next: boolean) => {
198
+ if (!isControlled) setInternalOpen(next);
199
+ onOpenChange?.(next);
200
+ },
201
+ [isControlled, onOpenChange]
202
+ );
203
+
204
+ // `Plan.Steps` counts its own children and reports up, so `Plan.Progress` can
205
+ // sit in the header — above the rail, and with no way to reach it — without
206
+ // the caller having to say how many steps there are twice.
207
+ const [counts, setCounts] = useState<PlanStepCounts | null>(null);
208
+ const reportCounts = useCallback((next: PlanStepCounts) => {
209
+ setCounts((current) =>
210
+ current && current.done === next.done && current.total === next.total ? current : next
211
+ );
212
+ }, []);
213
+
214
+ const context = useMemo(
215
+ () => ({ isStreaming, open, setOpen, counts, reportCounts }),
216
+ [isStreaming, open, setOpen, counts, reportCounts]
217
+ );
218
+
219
+ return (
220
+ <PlanContext.Provider value={context}>
221
+ <View {...props} className={cn(root(), className)}>
222
+ {children}
223
+ </View>
224
+ </PlanContext.Provider>
225
+ );
226
+ }
227
+ PlanRoot.displayName = 'Plan';
228
+
229
+ export interface PlanHeaderProps extends ViewProps {
230
+ className?: string;
231
+ children?: ReactNode;
232
+ }
233
+
234
+ /**
235
+ * The title, the description and whatever acts on them.
236
+ *
237
+ * The heading is wrapped in a column of its own so the action stays pinned to
238
+ * the trailing edge as the title wraps, rather than riding down with it. An
239
+ * icon is pulled out to the leading edge for the same reason, in the other
240
+ * direction — a badge that rode down with a wrapping title would stop reading
241
+ * as a badge for the plan.
242
+ */
243
+ function PlanHeader({ className, children, ...props }: PlanHeaderProps) {
244
+ const { header, heading } = planVariants();
245
+
246
+ const icons: ReactNode[] = [];
247
+ const headings: ReactNode[] = [];
248
+ const actions: ReactNode[] = [];
249
+ for (const child of Array.isArray(children) ? children : [children]) {
250
+ if (isType(child, PlanAction)) actions.push(child);
251
+ else if (isType(child, PlanIcon)) icons.push(child);
252
+ else headings.push(child);
253
+ }
254
+
255
+ return (
256
+ <View {...props} className={cn(header(), className)}>
257
+ {icons}
258
+ <View className={heading()}>{headings}</View>
259
+ {actions}
260
+ </View>
261
+ );
262
+ }
263
+
264
+ function isType(child: ReactNode, type: unknown): boolean {
265
+ return (
266
+ typeof child === 'object' &&
267
+ child !== null &&
268
+ 'type' in child &&
269
+ (child as { type?: unknown }).type === type
270
+ );
271
+ }
272
+
273
+ export interface PlanIconProps extends ViewProps {
274
+ className?: string;
275
+ children?: ReactNode;
276
+ }
277
+
278
+ /** A badge on the header's leading edge — what kind of plan this is. */
279
+ function PlanIcon({ className, children, ...props }: PlanIconProps) {
280
+ const { icon } = planVariants();
281
+ return (
282
+ <View {...props} className={cn(icon(), className)}>
283
+ {children}
284
+ </View>
285
+ );
286
+ }
287
+
288
+ export interface PlanTitleProps extends ViewProps {
289
+ className?: string;
290
+ children?: ReactNode;
291
+ }
292
+
293
+ /** The plan's name. Shimmers while it is still being written. */
294
+ function PlanTitle({ className, children, ...props }: PlanTitleProps) {
295
+ const { isStreaming } = usePlan('Plan.Title');
296
+ const { title } = planVariants();
297
+
298
+ if (isStreaming && typeof children === 'string') {
299
+ return <Shimmer textClassName={cn(title(), className)}>{children}</Shimmer>;
300
+ }
301
+
302
+ return (
303
+ <Text accessibilityRole="header" className={cn(title(), className)} {...props}>
304
+ {children}
305
+ </Text>
306
+ );
307
+ }
308
+
309
+ export interface PlanDescriptionProps extends ViewProps {
310
+ className?: string;
311
+ children?: ReactNode;
312
+ }
313
+
314
+ function PlanDescription({ className, children, ...props }: PlanDescriptionProps) {
315
+ const { isStreaming } = usePlan('Plan.Description');
316
+ const { description } = planVariants();
317
+
318
+ if (isStreaming && typeof children === 'string') {
319
+ return <Shimmer textClassName={cn(description(), className)}>{children}</Shimmer>;
320
+ }
321
+
322
+ return (
323
+ <Text className={cn(description(), className)} {...props}>
324
+ {children}
325
+ </Text>
326
+ );
327
+ }
328
+
329
+ export interface PlanActionProps extends ViewProps {
330
+ className?: string;
331
+ children?: ReactNode;
332
+ }
333
+
334
+ /** Pinned to the header's trailing edge — the toggle, a badge, a menu. */
335
+ function PlanAction({ className, children, ...props }: PlanActionProps) {
336
+ const { action } = planVariants();
337
+ return (
338
+ <View {...props} className={cn(action(), className)}>
339
+ {children}
340
+ </View>
341
+ );
342
+ }
343
+
344
+ export interface PlanTriggerProps extends Omit<PressableProps, 'children' | 'style'> {
345
+ className?: string;
346
+ children?: ReactNode;
347
+ }
348
+
349
+ /** Folds the body away. Its chevron turns to point at the state it will reach. */
350
+ function PlanTrigger({ className, children, onPress, ...props }: PlanTriggerProps) {
351
+ const { open, setOpen } = usePlan('Plan.Trigger');
352
+ const { trigger } = planVariants();
353
+ const reducedMotion = useReducedMotion();
354
+ const progress = useSharedValue(open ? 1 : 0);
355
+
356
+ useEffect(() => {
357
+ progress.value = reducedMotion
358
+ ? open
359
+ ? 1
360
+ : 0
361
+ : withTiming(open ? 1 : 0, { duration: 180 });
362
+ }, [open, reducedMotion, progress]);
363
+
364
+ const chevronStyle = useAnimatedStyle(() => ({
365
+ transform: [{ rotate: `${progress.value * 180}deg` }],
366
+ }));
367
+
368
+ return (
369
+ <Pressable
370
+ accessibilityRole="button"
371
+ accessibilityLabel={open ? 'Collapse plan' : 'Expand plan'}
372
+ accessibilityState={{ expanded: open }}
373
+ hitSlop={6}
374
+ onPress={(event) => {
375
+ onPress?.(event);
376
+ setOpen(!open);
377
+ }}
378
+ className={cn(trigger(), className)}
379
+ {...props}
380
+ >
381
+ {children ?? (
382
+ <Animated.View style={chevronStyle}>
383
+ <ChevronDownIcon size={16} />
384
+ </Animated.View>
385
+ )}
386
+ </Pressable>
387
+ );
388
+ }
389
+
390
+ export interface PlanContentProps extends Omit<ViewProps, 'children'> {
391
+ className?: string;
392
+ children?: ReactNode;
393
+ }
394
+
395
+ /** The steps. Collapses rather than unmounting, so it can still be growing. */
396
+ function PlanContent({ className, children, ...props }: PlanContentProps) {
397
+ const { open } = usePlan('Plan.Content');
398
+ const { content, description } = planVariants();
399
+
400
+ return (
401
+ <Collapse open={open} className={cn(content(), className)} {...props}>
402
+ {textChildren(children, (text) => (
403
+ <Text className={description()}>{text}</Text>
404
+ ))}
405
+ </Collapse>
406
+ );
407
+ }
408
+
409
+ /**
410
+ * Where a step sits in the rail. Provided by `Plan.Steps` rather than passed
411
+ * down, so steps produced by a `.map()` through a component of your own still
412
+ * know they are last — a prop set on that wrapper would never reach the step.
413
+ */
414
+ const PlanStepContext = createContext<{ last: boolean }>({ last: false });
415
+
416
+ export interface PlanStepsProps extends ViewProps {
417
+ className?: string;
418
+ children?: ReactNode;
419
+ }
420
+
421
+ /**
422
+ * The rail. A plan is an ordered list of things that will happen, and this is
423
+ * the list — a paragraph describing the same four steps cannot be glanced at to
424
+ * see which one is running.
425
+ *
426
+ * It counts its own steps and reports the count up, which is what lets
427
+ * `Plan.Progress` sit in the header without the caller stating the total twice.
428
+ */
429
+ function PlanSteps({ className, children, ...props }: PlanStepsProps) {
430
+ const { reportCounts } = usePlan('Plan.Steps');
431
+ const { steps } = planVariants();
432
+
433
+ const items = useMemo(
434
+ () => Children.toArray(children).filter((child) => isValidElement(child)),
435
+ [children]
436
+ );
437
+
438
+ const counts = useMemo(() => {
439
+ let done = 0;
440
+ let total = 0;
441
+ for (const item of items) {
442
+ if (!isType(item, PlanStep)) continue;
443
+ total += 1;
444
+ // A skipped step is settled too — a plan that reports 2 of 4 while two
445
+ // more were deliberately passed over is reporting the wrong thing.
446
+ const status = (item as ReactElement<PlanStepProps>).props.status;
447
+ if (status === 'done' || status === 'skipped') done += 1;
448
+ }
449
+ return { done, total };
450
+ }, [items]);
451
+
452
+ useEffect(() => {
453
+ reportCounts(counts);
454
+ }, [counts, reportCounts]);
455
+
456
+ return (
457
+ <View {...props} role="list" className={cn(steps(), className)}>
458
+ {items.map((item, index) => (
459
+ <PlanStepContext.Provider
460
+ // The step's own key stays on the element; this wrapper needs its own.
461
+ key={index}
462
+ value={{ last: index === items.length - 1 }}
463
+ >
464
+ {item}
465
+ </PlanStepContext.Provider>
466
+ ))}
467
+ </View>
468
+ );
469
+ }
470
+
471
+ export interface PlanStepProps extends Omit<ViewProps, 'children'> {
472
+ className?: string;
473
+ /** How far this step has got. Decides the marker, the title and the rail below it. */
474
+ status?: PlanStepStatus;
475
+ /** A line under the title — what the step will touch, or what it found. */
476
+ description?: ReactNode;
477
+ /** A file path, a count, a duration. Rendered as a small mono chip. */
478
+ meta?: ReactNode;
479
+ /** Drop the connector below this step. `Plan.Steps` sets it for you. */
480
+ last?: boolean;
481
+ children?: ReactNode;
482
+ }
483
+
484
+ /**
485
+ * One step. The marker says which of the four states it is in and the rail
486
+ * below it is filled once it is done, so the plan's progress is legible from
487
+ * the shape of the left-hand column alone.
488
+ */
489
+ function PlanStep({
490
+ className,
491
+ status = 'pending',
492
+ description,
493
+ meta,
494
+ last,
495
+ children,
496
+ ...props
497
+ }: PlanStepProps) {
498
+ const { isStreaming } = usePlan('Plan.Step');
499
+ const position = useContext(PlanStepContext);
500
+ const isLast = last ?? position.last;
501
+ const {
502
+ step,
503
+ stepRail,
504
+ stepMarker,
505
+ stepConnector,
506
+ stepBody,
507
+ stepTitle,
508
+ stepDescription,
509
+ stepMeta,
510
+ } = planVariants();
511
+ const tone = stepStatus[status];
512
+ const onPrimary = useCSSVariable('--color-primary-foreground');
513
+
514
+ const title = stepTitle({ className: tone.title });
515
+ // Only the step that is running is still arriving. Shimmering a finished one
516
+ // would say it is still being written, and shimmering the whole rail would
517
+ // say nothing at all.
518
+ const streamingTitle = isStreaming && status === 'active' && typeof children === 'string';
519
+
520
+ return (
521
+ <View {...props} role="listitem" className={cn(step(), className)}>
522
+ <View className={stepRail()}>
523
+ <View className={stepMarker({ className: tone.marker })}>
524
+ {status === 'done' ? (
525
+ <CheckIcon
526
+ size={10}
527
+ color={typeof onPrimary === 'string' ? onPrimary : '#ffffff'}
528
+ />
529
+ ) : status === 'active' ? (
530
+ <View className="h-1.5 w-1.5 rounded-full bg-primary" />
531
+ ) : null}
532
+ </View>
533
+ {isLast ? null : <View className={stepConnector({ className: tone.connector })} />}
534
+ </View>
535
+
536
+ <View className={cn(stepBody(), isLast && 'pb-0')}>
537
+ {streamingTitle ? (
538
+ <Shimmer textClassName={title}>{children as string}</Shimmer>
539
+ ) : (
540
+ textChildren(children, (text) => <Text className={title}>{text}</Text>)
541
+ )}
542
+ {textChildren(description, (text) => (
543
+ <Text className={stepDescription()}>{text}</Text>
544
+ ))}
545
+ {meta === undefined ? null : (
546
+ <View className={stepMeta()}>
547
+ {textChildren(meta, (text) => (
548
+ <Text className="font-mono text-[11px] text-muted-foreground">{text}</Text>
549
+ ))}
550
+ </View>
551
+ )}
552
+ </View>
553
+ </View>
554
+ );
555
+ }
556
+
557
+ export interface PlanProgressProps extends TextProps {
558
+ className?: string;
559
+ /** Steps settled so far. Defaults to what `Plan.Steps` counted. */
560
+ value?: number;
561
+ /** Steps in total. Defaults to what `Plan.Steps` counted. */
562
+ total?: number;
563
+ }
564
+
565
+ /**
566
+ * How far down the rail the plan has got, as `2 of 4`. Renders nothing until
567
+ * there is a rail to count — a plan with no steps has no progress to report,
568
+ * and `0 of 0` is worse than silence.
569
+ */
570
+ function PlanProgress({ className, value, total, ...props }: PlanProgressProps) {
571
+ const { counts } = usePlan('Plan.Progress');
572
+ const { progress } = planVariants();
573
+
574
+ const done = value ?? counts?.done;
575
+ const all = total ?? counts?.total;
576
+ if (done === undefined || !all) return null;
577
+
578
+ return (
579
+ <Text className={cn(progress(), className)} {...props}>
580
+ {done} of {all}
581
+ </Text>
582
+ );
583
+ }
584
+
585
+ export interface PlanFooterProps extends ViewProps {
586
+ className?: string;
587
+ /**
588
+ * How the actions divide the row. `stretch` splits it between them, which is
589
+ * what a phone wants: the decision is the point of the card, and the two
590
+ * buttons that make it should be the width of a thumb. `end` packs them
591
+ * against the trailing edge for a plan sitting inside something denser.
592
+ */
593
+ layout?: 'stretch' | 'end';
594
+ children?: ReactNode;
595
+ }
596
+
597
+ /**
598
+ * Where the buttons that answer the plan go.
599
+ *
600
+ * Each action takes an equal share of the row by default. A pair of small
601
+ * buttons hugging the trailing corner is a pointer-and-cursor shape; on a phone
602
+ * the answer to "shall I do this" is the most important control on the screen
603
+ * and wants to be hit without aiming.
604
+ */
605
+ function PlanFooter({ className, layout = 'stretch', children, ...props }: PlanFooterProps) {
606
+ const { footer } = planVariants();
607
+
608
+ return (
609
+ <View
610
+ {...props}
611
+ className={cn(footer(), layout === 'end' && 'justify-end', className)}
612
+ >
613
+ {layout === 'stretch'
614
+ ? // A view stretches its children across the cross axis by default, so
615
+ // a button inside one of these fills it without being told to.
616
+ Children.map(children, (child) =>
617
+ isValidElement(child) ? <View className="flex-1">{child}</View> : child
618
+ )
619
+ : children}
620
+ </View>
621
+ );
622
+ }
623
+
624
+ PlanHeader.displayName = 'Plan.Header';
625
+ PlanIcon.displayName = 'Plan.Icon';
626
+ PlanTitle.displayName = 'Plan.Title';
627
+ PlanDescription.displayName = 'Plan.Description';
628
+ PlanAction.displayName = 'Plan.Action';
629
+ PlanProgress.displayName = 'Plan.Progress';
630
+ PlanTrigger.displayName = 'Plan.Trigger';
631
+ PlanContent.displayName = 'Plan.Content';
632
+ PlanSteps.displayName = 'Plan.Steps';
633
+ PlanStep.displayName = 'Plan.Step';
634
+ PlanFooter.displayName = 'Plan.Footer';
635
+
636
+ export const Plan = Object.assign(PlanRoot, {
637
+ Header: PlanHeader,
638
+ Icon: PlanIcon,
639
+ Title: PlanTitle,
640
+ Description: PlanDescription,
641
+ Action: PlanAction,
642
+ Progress: PlanProgress,
643
+ Trigger: PlanTrigger,
644
+ Content: PlanContent,
645
+ Steps: PlanSteps,
646
+ Step: PlanStep,
647
+ Footer: PlanFooter,
648
+ });