panelui-native 0.30.0 → 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 (61) hide show
  1. package/README.md +4 -2
  2. package/lib/module/components/calendar/index.js +107 -24
  3. package/lib/module/components/calendar/index.js.map +1 -1
  4. package/lib/module/components/heatmap-chart/index.js +9 -8
  5. package/lib/module/components/heatmap-chart/index.js.map +1 -1
  6. package/lib/module/components/map/index.js +50 -4
  7. package/lib/module/components/map/index.js.map +1 -1
  8. package/lib/module/components/plan/index.js +307 -21
  9. package/lib/module/components/plan/index.js.map +1 -1
  10. package/lib/module/components/post/index.js +992 -0
  11. package/lib/module/components/post/index.js.map +1 -0
  12. package/lib/module/components/response/index.js +296 -0
  13. package/lib/module/components/response/index.js.map +1 -0
  14. package/lib/module/components/response/markdown.js +463 -0
  15. package/lib/module/components/response/markdown.js.map +1 -0
  16. package/lib/module/components/select/index.js +97 -15
  17. package/lib/module/components/select/index.js.map +1 -1
  18. package/lib/module/components/table/index.js +632 -0
  19. package/lib/module/components/table/index.js.map +1 -0
  20. package/lib/module/icons/index.js +200 -0
  21. package/lib/module/icons/index.js.map +1 -1
  22. package/lib/module/index.js +4 -1
  23. package/lib/module/index.js.map +1 -1
  24. package/lib/module/utils/date.js +129 -22
  25. package/lib/module/utils/date.js.map +1 -1
  26. package/lib/typescript/src/components/calendar/index.d.ts +12 -3
  27. package/lib/typescript/src/components/calendar/index.d.ts.map +1 -1
  28. package/lib/typescript/src/components/heatmap-chart/index.d.ts.map +1 -1
  29. package/lib/typescript/src/components/map/index.d.ts +8 -1
  30. package/lib/typescript/src/components/map/index.d.ts.map +1 -1
  31. package/lib/typescript/src/components/plan/index.d.ts +121 -10
  32. package/lib/typescript/src/components/plan/index.d.ts.map +1 -1
  33. package/lib/typescript/src/components/post/index.d.ts +358 -0
  34. package/lib/typescript/src/components/post/index.d.ts.map +1 -0
  35. package/lib/typescript/src/components/response/index.d.ts +91 -0
  36. package/lib/typescript/src/components/response/index.d.ts.map +1 -0
  37. package/lib/typescript/src/components/response/markdown.d.ts +88 -0
  38. package/lib/typescript/src/components/response/markdown.d.ts.map +1 -0
  39. package/lib/typescript/src/components/select/index.d.ts +27 -2
  40. package/lib/typescript/src/components/select/index.d.ts.map +1 -1
  41. package/lib/typescript/src/components/table/index.d.ts +274 -0
  42. package/lib/typescript/src/components/table/index.d.ts.map +1 -0
  43. package/lib/typescript/src/icons/index.d.ts +40 -1
  44. package/lib/typescript/src/icons/index.d.ts.map +1 -1
  45. package/lib/typescript/src/index.d.ts +5 -2
  46. package/lib/typescript/src/index.d.ts.map +1 -1
  47. package/lib/typescript/src/utils/date.d.ts +49 -5
  48. package/lib/typescript/src/utils/date.d.ts.map +1 -1
  49. package/package.json +1 -1
  50. package/src/components/calendar/index.tsx +136 -23
  51. package/src/components/heatmap-chart/index.tsx +9 -9
  52. package/src/components/map/index.tsx +53 -4
  53. package/src/components/plan/index.tsx +344 -21
  54. package/src/components/post/index.tsx +1116 -0
  55. package/src/components/response/index.tsx +390 -0
  56. package/src/components/response/markdown.ts +489 -0
  57. package/src/components/select/index.tsx +132 -12
  58. package/src/components/table/index.tsx +747 -0
  59. package/src/icons/index.tsx +162 -1
  60. package/src/index.ts +60 -0
  61. package/src/utils/date.ts +146 -22
@@ -6,20 +6,41 @@
6
6
  * and stops is not a matter of interpretation, and a footer that can hold the
7
7
  * button that approves it.
8
8
  *
9
- * It streams. `isStreaming` puts a shimmer on the title and the description,
10
- * which is the honest way to render a heading that is still arriving — the
11
- * alternative is a title that grows a word at a time and reads as finished at
12
- * every intermediate length.
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.
13
21
  *
14
22
  * ```tsx
15
23
  * <Plan isStreaming={streaming}>
16
24
  * <Plan.Header>
25
+ * <Plan.Icon><FileIcon size={16} /></Plan.Icon>
17
26
  * <Plan.Title>Migrate the calendar</Plan.Title>
18
27
  * <Plan.Description>Four files, no API change.</Plan.Description>
19
- * <Plan.Action><Plan.Trigger /></Plan.Action>
28
+ * <Plan.Action>
29
+ * <Plan.Progress />
30
+ * <Plan.Trigger />
31
+ * </Plan.Action>
20
32
  * </Plan.Header>
21
- * <Plan.Content>…</Plan.Content>
22
- * <Plan.Footer><Button>Approve</Button></Plan.Footer>
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>
23
44
  * </Plan>
24
45
  * ```
25
46
  *
@@ -31,12 +52,15 @@
31
52
  * a title before it has anything else.
32
53
  */
33
54
  import {
55
+ Children,
34
56
  createContext,
57
+ isValidElement,
35
58
  useCallback,
36
59
  useContext,
37
60
  useEffect,
38
61
  useMemo,
39
62
  useState,
63
+ type ReactElement,
40
64
  type ReactNode,
41
65
  } from 'react';
42
66
  import { Pressable, View, type PressableProps, type ViewProps } from 'react-native';
@@ -47,30 +71,90 @@ import Animated, {
47
71
  withTiming,
48
72
  } from 'react-native-reanimated';
49
73
  import { tv } from 'tailwind-variants';
50
- import { ChevronDownIcon } from '../../icons';
74
+ import { useCSSVariable } from 'uniwind';
75
+ import { CheckIcon, ChevronDownIcon } from '../../icons';
51
76
  import { Collapse } from '../../primitives/collapse';
52
- import { Text, textChildren } from '../../primitives/text';
77
+ import { Text, textChildren, type TextProps } from '../../primitives/text';
53
78
  import { cn } from '../../utils/cn';
54
79
  import { Shimmer } from '../shimmer';
55
80
 
56
81
  const planVariants = tv({
57
82
  slots: {
58
- root: 'w-full overflow-hidden rounded-2xl border border-border bg-card',
83
+ root: 'w-full overflow-hidden rounded-2xl border border-border bg-card shadow-sm',
59
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',
60
86
  heading: 'min-w-0 flex-1 gap-1',
61
87
  title: 'text-base font-semibold text-card-foreground',
62
88
  description: 'text-sm text-muted-foreground',
63
89
  action: 'shrink-0 flex-row items-center gap-1',
90
+ progress: 'text-xs font-medium text-muted-foreground',
64
91
  trigger: 'h-8 w-8 items-center justify-center rounded-full active:bg-accent',
65
92
  content: 'gap-2 px-4 pb-4',
66
- footer: 'flex-row items-center justify-end gap-2 border-t border-border p-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',
67
104
  },
68
105
  });
69
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
+
70
152
  interface PlanContextValue {
71
153
  isStreaming: boolean;
72
154
  open: boolean;
73
155
  setOpen: (open: boolean) => void;
156
+ counts: PlanStepCounts | null;
157
+ reportCounts: (counts: PlanStepCounts) => void;
74
158
  }
75
159
 
76
160
  const PlanContext = createContext<PlanContextValue | null>(null);
@@ -117,9 +201,19 @@ function PlanRoot({
117
201
  [isControlled, onOpenChange]
118
202
  );
119
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
+
120
214
  const context = useMemo(
121
- () => ({ isStreaming, open, setOpen }),
122
- [isStreaming, open, setOpen]
215
+ () => ({ isStreaming, open, setOpen, counts, reportCounts }),
216
+ [isStreaming, open, setOpen, counts, reportCounts]
123
217
  );
124
218
 
125
219
  return (
@@ -141,32 +235,53 @@ export interface PlanHeaderProps extends ViewProps {
141
235
  * The title, the description and whatever acts on them.
142
236
  *
143
237
  * The heading is wrapped in a column of its own so the action stays pinned to
144
- * the trailing edge as the title wraps, rather than riding down with it.
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.
145
242
  */
146
243
  function PlanHeader({ className, children, ...props }: PlanHeaderProps) {
147
244
  const { header, heading } = planVariants();
148
245
 
246
+ const icons: ReactNode[] = [];
149
247
  const headings: ReactNode[] = [];
150
248
  const actions: ReactNode[] = [];
151
249
  for (const child of Array.isArray(children) ? children : [children]) {
152
- if (isPlanAction(child)) actions.push(child);
250
+ if (isType(child, PlanAction)) actions.push(child);
251
+ else if (isType(child, PlanIcon)) icons.push(child);
153
252
  else headings.push(child);
154
253
  }
155
254
 
156
255
  return (
157
256
  <View {...props} className={cn(header(), className)}>
257
+ {icons}
158
258
  <View className={heading()}>{headings}</View>
159
259
  {actions}
160
260
  </View>
161
261
  );
162
262
  }
163
263
 
164
- function isPlanAction(child: ReactNode): boolean {
264
+ function isType(child: ReactNode, type: unknown): boolean {
165
265
  return (
166
266
  typeof child === 'object' &&
167
267
  child !== null &&
168
268
  'type' in child &&
169
- (child as { type?: unknown }).type === PlanAction
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>
170
285
  );
171
286
  }
172
287
 
@@ -291,35 +406,243 @@ function PlanContent({ className, children, ...props }: PlanContentProps) {
291
406
  );
292
407
  }
293
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
+
294
585
  export interface PlanFooterProps extends ViewProps {
295
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';
296
594
  children?: ReactNode;
297
595
  }
298
596
 
299
- /** Where the button that approves the plan goes. */
300
- function PlanFooter({ className, children, ...props }: PlanFooterProps) {
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) {
301
606
  const { footer } = planVariants();
607
+
302
608
  return (
303
- <View {...props} className={cn(footer(), className)}>
304
- {children}
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}
305
620
  </View>
306
621
  );
307
622
  }
308
623
 
309
624
  PlanHeader.displayName = 'Plan.Header';
625
+ PlanIcon.displayName = 'Plan.Icon';
310
626
  PlanTitle.displayName = 'Plan.Title';
311
627
  PlanDescription.displayName = 'Plan.Description';
312
628
  PlanAction.displayName = 'Plan.Action';
629
+ PlanProgress.displayName = 'Plan.Progress';
313
630
  PlanTrigger.displayName = 'Plan.Trigger';
314
631
  PlanContent.displayName = 'Plan.Content';
632
+ PlanSteps.displayName = 'Plan.Steps';
633
+ PlanStep.displayName = 'Plan.Step';
315
634
  PlanFooter.displayName = 'Plan.Footer';
316
635
 
317
636
  export const Plan = Object.assign(PlanRoot, {
318
637
  Header: PlanHeader,
638
+ Icon: PlanIcon,
319
639
  Title: PlanTitle,
320
640
  Description: PlanDescription,
321
641
  Action: PlanAction,
642
+ Progress: PlanProgress,
322
643
  Trigger: PlanTrigger,
323
644
  Content: PlanContent,
645
+ Steps: PlanSteps,
646
+ Step: PlanStep,
324
647
  Footer: PlanFooter,
325
648
  });