panelui-native 0.93.0 → 0.95.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 (30) hide show
  1. package/README.md +1 -0
  2. package/lib/module/components/bottom-sheet/index.js +19 -2
  3. package/lib/module/components/bottom-sheet/index.js.map +1 -1
  4. package/lib/module/components/bubble-chart/index.js +23 -9
  5. package/lib/module/components/bubble-chart/index.js.map +1 -1
  6. package/lib/module/components/questionnaire/index.js +242 -22
  7. package/lib/module/components/questionnaire/index.js.map +1 -1
  8. package/lib/module/components/scroll-header/index.js +650 -0
  9. package/lib/module/components/scroll-header/index.js.map +1 -0
  10. package/lib/module/components/scroll-header/scroll-header-math.js +144 -0
  11. package/lib/module/components/scroll-header/scroll-header-math.js.map +1 -0
  12. package/lib/module/index.js +1 -0
  13. package/lib/module/index.js.map +1 -1
  14. package/lib/typescript/src/components/bottom-sheet/index.d.ts.map +1 -1
  15. package/lib/typescript/src/components/bubble-chart/index.d.ts.map +1 -1
  16. package/lib/typescript/src/components/questionnaire/index.d.ts +19 -5
  17. package/lib/typescript/src/components/questionnaire/index.d.ts.map +1 -1
  18. package/lib/typescript/src/components/scroll-header/index.d.ts +237 -0
  19. package/lib/typescript/src/components/scroll-header/index.d.ts.map +1 -0
  20. package/lib/typescript/src/components/scroll-header/scroll-header-math.d.ts +99 -0
  21. package/lib/typescript/src/components/scroll-header/scroll-header-math.d.ts.map +1 -0
  22. package/lib/typescript/src/index.d.ts +1 -0
  23. package/lib/typescript/src/index.d.ts.map +1 -1
  24. package/package.json +1 -1
  25. package/src/components/bottom-sheet/index.tsx +29 -3
  26. package/src/components/bubble-chart/index.tsx +29 -9
  27. package/src/components/questionnaire/index.tsx +249 -19
  28. package/src/components/scroll-header/index.tsx +814 -0
  29. package/src/components/scroll-header/scroll-header-math.ts +147 -0
  30. package/src/index.ts +9 -0
@@ -39,6 +39,15 @@
39
39
  * question needs, for free. Pass `frame={false}` to drop it — for a
40
40
  * questionnaire inside a `BottomSheet` or a card that already draws a border.
41
41
  *
42
+ * It is the `inset` variant, so the panel the question is written on floats in
43
+ * a recessed band rather than sitting flush in a tray, and the actions go in
44
+ * the band rather than in a section under the question. That separation is the
45
+ * point: the question changes and the row under it does not, and a row drawn
46
+ * on the band is visibly not part of the card that keeps being replaced.
47
+ *
48
+ * The band shapes its actions into equal pills, which is why
49
+ * `Questionnaire.Spacer` is dropped on the way in — see `bandActions`.
50
+ *
42
51
  * ## Why the root reads its children instead of collecting registrations
43
52
  *
44
53
  * Only the active question is mounted, so an unmounted one cannot report that
@@ -50,8 +59,8 @@
50
59
  * which makes the whole set knowable without mounting any of it.
51
60
  *
52
61
  * That same pass sorts the parts into the shell: the title and progress go to
53
- * the header strip, the footer to a section at the bottom of the panel, and
54
- * everything else is a question.
62
+ * the header strip above the panel, the footer's actions to the band around
63
+ * it, and everything else is a question.
55
64
  *
56
65
  * ## Answers are one record, the way a form would submit them
57
66
  *
@@ -101,12 +110,15 @@ import Animated, {
101
110
  Easing,
102
111
  FadeOut,
103
112
  runOnJS,
113
+ useAnimatedProps,
104
114
  useAnimatedStyle,
115
+ useReducedMotion,
105
116
  useSharedValue,
106
117
  withSpring,
107
118
  withTiming,
108
119
  type EntryExitAnimationFunction,
109
120
  } from 'react-native-reanimated';
121
+ import Svg, { Circle } from 'react-native-svg';
110
122
  import { tv } from 'tailwind-variants';
111
123
  import { useCSSVariable } from 'uniwind';
112
124
  import { CheckIcon, ChevronLeftIcon, ChevronRightIcon } from '../../icons';
@@ -131,6 +143,16 @@ const EXIT_DURATION = 140;
131
143
  /** How long a progress pip takes to fill once its question has been reached. */
132
144
  const PIP_DURATION = 260;
133
145
 
146
+ /**
147
+ * The progress ring: its diameter, and the weight of the arc drawn round it.
148
+ *
149
+ * Sized against the title beside it rather than against the strip it sits on.
150
+ * A ring the height of a line of text reads as the trailing half of a pair;
151
+ * one much larger reads as the header's subject, which it is not.
152
+ */
153
+ const RING_SIZE = 22;
154
+ const RING_STROKE = 2.5;
155
+
134
156
  /** How far across the body a swipe has to travel before it commits. */
135
157
  const SWIPE_FRACTION = 0.25;
136
158
 
@@ -272,6 +294,11 @@ interface QuestionnaireContextValue {
272
294
  shortcuts: QuestionnaireShortcutMode | null;
273
295
  /** Whether the root drew the frame, which decides who owns the insets. */
274
296
  framed: boolean;
297
+ /**
298
+ * The question is drawn on the header strip rather than in the panel, so
299
+ * `Questionnaire.Question` renders nothing where it stands.
300
+ */
301
+ questionOnStrip: boolean;
275
302
  /** The active question is required and has no answer, so the way on is shut. */
276
303
  blocked: boolean;
277
304
  }
@@ -314,6 +341,80 @@ function isAnswered(value: string | string[] | undefined): boolean {
314
341
  return typeof value === 'string' && value.trim().length > 0;
315
342
  }
316
343
 
344
+ /**
345
+ * The footer's actions, on their way into the band.
346
+ *
347
+ * `Frame.Footer` shapes its *direct* children into the band's pills, so the
348
+ * row itself has to be unwrapped — handed the `Questionnaire.Footer` element
349
+ * whole it would be the wrapper that came out a pill, with the buttons
350
+ * untouched inside it.
351
+ *
352
+ * The spacer goes at the same time. It exists to push the actions apart in a
353
+ * row that lays them out at their own widths; in a band where every action is
354
+ * already a share of the row it would take a share of its own, and the buttons
355
+ * would come out narrower the more carefully the caller had spaced them.
356
+ *
357
+ * The band's own rule is equal widths, and it is the wrong one here. A row of
358
+ * equal pills says these are equal decisions; Back and Skip are ways off the
359
+ * path and Next is the path, so they are sized to their labels and the primary
360
+ * action takes what is left. Equal thirds also truncate it — "Continue" does
361
+ * not fit in a third of the band, and the button that carries the flow is the
362
+ * worst one to lose the end of.
363
+ *
364
+ * `flex-none` and nothing else on those two: the button's own padding is what
365
+ * a label needs either side of it, and anything added here comes off the
366
+ * primary action, which has the longest label and the least room to lose.
367
+ *
368
+ * The primary action goes up one size instead — `lg`, not the default. It is
369
+ * the only thing on the band anybody is aiming for: the whole widget is one
370
+ * decision repeated, and this is the button that takes the reader to the next
371
+ * one, so it carries a larger label and a taller box than the way back beside
372
+ * it. One size and no more; `xl` is for a button that is the whole of a
373
+ * screen, and this one shares its row. The band's fixed height comes off with
374
+ * it, or the taller box would be clamped back to the row's.
375
+ */
376
+ function bandActions(footer: ReactNode): ReactNode {
377
+ if (!isValidElement<QuestionnaireFooterProps>(footer)) return footer;
378
+ return Children.toArray(footer.props.children).flatMap((child) => {
379
+ if (!isValidElement<QuestionnaireActionProps>(child)) return [child];
380
+ if (child.type === QuestionnaireSpacer) return [];
381
+
382
+ // Merged after the band's own classes rather than before, so what is set
383
+ // here is what survives.
384
+ if (child.type === QuestionnaireBack || child.type === QuestionnaireSkip) {
385
+ return [cloneElement(child, { className: cn('flex-none', child.props.className) })];
386
+ }
387
+
388
+ return [
389
+ cloneElement(child, {
390
+ // A caller who asked for a size meant it.
391
+ size: child.props.size ?? 'lg',
392
+ className: cn('h-auto', child.props.className),
393
+ }),
394
+ ];
395
+ });
396
+ }
397
+
398
+ /**
399
+ * The text of a question's `Questionnaire.Question`, for the header strip.
400
+ *
401
+ * Read off the element rather than reported up by the mounted question: only
402
+ * the active one is mounted, and the strip has to be drawn in the same render
403
+ * as the pane it labels — a value arriving from below would land one frame
404
+ * late, which on a question change is a header still naming the question that
405
+ * has just left.
406
+ */
407
+ function questionOf(item: ReactElement<QuestionnaireItemProps> | undefined): ReactNode {
408
+ if (!item) return null;
409
+ let found: ReactNode = null;
410
+ Children.forEach(item.props.children, (child) => {
411
+ if (found != null) return;
412
+ if (!isValidElement<QuestionnaireQuestionProps>(child)) return;
413
+ if (child.type === QuestionnaireQuestion) found = child.props.children;
414
+ });
415
+ return found;
416
+ }
417
+
317
418
  /** The letter or number an answer at this position is badged with. */
318
419
  function shortcutAt(mode: QuestionnaireShortcutMode, index: number): string | null {
319
420
  if (mode === 'numbers') return index < 9 ? String(index + 1) : null;
@@ -621,6 +722,29 @@ function QuestionnaireRoot({
621
722
  */
622
723
  const blocked = !!activeItem?.required && !isAnswered(answers[activeItem.name]);
623
724
 
725
+ /*
726
+ * With no title the header strip has nothing on it but the ring, and a ring
727
+ * centred on an empty strip is a widget that will not say what it is asking.
728
+ * The question stands in for the title instead — it is the only line that
729
+ * names the thing, and a questionnaire that has not been given a title is
730
+ * one where the question is the title.
731
+ *
732
+ * It takes the title's place exactly, at the leading edge with the ring
733
+ * still at the trailing one, so a titled questionnaire and an untitled one
734
+ * draw the same strip. Two arrangements of the same two things would make
735
+ * the header look like it meant something different in each.
736
+ *
737
+ * The trade is that the question no longer travels with the pane: the strip
738
+ * stays put while the answers slide under it. That is the right way round
739
+ * for a label, and the wrong way round for a question that is meant to
740
+ * arrive with its answers — which is why giving it a title turns this off.
741
+ */
742
+ const questionOnStrip = frame && !titleNode;
743
+ const stripQuestion = useMemo(
744
+ () => (questionOnStrip ? questionOf(activeItem?.element) : null),
745
+ [questionOnStrip, activeItem]
746
+ );
747
+
624
748
  const context = useMemo<QuestionnaireContextValue>(
625
749
  () => ({
626
750
  current,
@@ -640,6 +764,7 @@ function QuestionnaireRoot({
640
764
  submit,
641
765
  shortcuts: shortcuts ?? null,
642
766
  framed: frame,
767
+ questionOnStrip,
643
768
  blocked,
644
769
  }),
645
770
  [
@@ -660,6 +785,7 @@ function QuestionnaireRoot({
660
785
  submit,
661
786
  shortcuts,
662
787
  frame,
788
+ questionOnStrip,
663
789
  blocked,
664
790
  ]
665
791
  );
@@ -679,10 +805,11 @@ function QuestionnaireRoot({
679
805
  );
680
806
 
681
807
  /*
682
- * More room above and below than a Frame header takes by default. That
683
- * default is set for a line of text, and the progress indicator is a 4px
684
- * bar — against the same padding it reads as pinned to the top edge rather
685
- * than sitting on the strip.
808
+ * A little more room under the strip than the `inset` header takes by
809
+ * default. That default is set for a line of text; the ring is a shape whose
810
+ * stroke reaches the edge of its box, with none of the leading a line of
811
+ * text has, so against the same padding it sits closer to the panel than the
812
+ * title beside it does.
686
813
  *
687
814
  * With no title, the progress centres rather than staying hard right. Right
688
815
  * is where it belongs when it is the trailing half of a pair; on its own at
@@ -690,8 +817,17 @@ function QuestionnaireRoot({
690
817
  * rather than as the strip's subject.
691
818
  */
692
819
  const header = (
693
- <Frame.Header className={cn('pb-3.5 pt-3', !titleNode && 'justify-center')}>
694
- {titleNode}
820
+ <Frame.Header className="pb-3 pt-1.5">
821
+ {titleNode ??
822
+ textChildren(stripQuestion, (text) => (
823
+ // One line, and it truncates rather than wrapping: the strip is a
824
+ // label for the panel under it, and a label that grows to two lines
825
+ // pushes the answers down the screen every time a longer question
826
+ // comes round.
827
+ <Text size="sm" weight="medium" numberOfLines={1} className="min-w-0 shrink">
828
+ {text}
829
+ </Text>
830
+ ))}
695
831
  {progressNode ? <Frame.Action>{progressNode}</Frame.Action> : null}
696
832
  </Frame.Header>
697
833
  );
@@ -699,12 +835,23 @@ function QuestionnaireRoot({
699
835
  return (
700
836
  <QuestionnaireContext.Provider value={context}>
701
837
  {frame ? (
702
- <Frame className={className} {...props}>
838
+ /*
839
+ * The panel floats in a recessed band rather than sitting flush in a
840
+ * tray, and the band is where the actions go. A questionnaire is a
841
+ * screen's worth of one decision repeated — answer, then move — and
842
+ * the band draws that row as equal pills clear of the card the
843
+ * question is written on, which is the distinction the old footer
844
+ * section inside the panel did not make.
845
+ */
846
+ <Frame variant="inset" className={className} {...props}>
703
847
  {header}
704
- <Frame.Panel dividers={false}>
705
- {body}
706
- {footerNode ? <Frame.Section divided>{footerNode}</Frame.Section> : null}
707
- </Frame.Panel>
848
+ <Frame.Panel dividers={false}>{body}</Frame.Panel>
849
+ {footerNode ? (
850
+ // Tighter than the band's own gap. That one is set for two or
851
+ // three equal pills; this row is three actions of three different
852
+ // widths, and every point between them comes off the longest label.
853
+ <Frame.Footer className="gap-2">{bandActions(footerNode)}</Frame.Footer>
854
+ ) : null}
708
855
  </Frame>
709
856
  ) : (
710
857
  <View className={cn('w-full', className)} {...props}>
@@ -964,18 +1111,23 @@ export interface QuestionnaireProgressState {
964
1111
  }
965
1112
 
966
1113
  /** How the position is drawn. */
967
- export type QuestionnaireProgressVariant = 'pips' | 'numbers' | 'count';
1114
+ export type QuestionnaireProgressVariant = 'ring' | 'pips' | 'numbers' | 'count';
968
1115
 
969
1116
  export interface QuestionnaireProgressProps {
970
1117
  className?: string;
971
1118
  /**
1119
+ * `ring` is an arc that sweeps round as the reader advances — how far
1120
+ * through the set they are, without saying how many questions there are.
1121
+ * It is the only one that holds its size and its meaning at any length,
1122
+ * which is why it is the default.
1123
+ *
972
1124
  * `pips` is a bar per question, filled up to the one being asked and widened
973
1125
  * on it. `numbers` counts them out instead, which is what you want when the
974
1126
  * reader will be sent back to a particular question. `count` is the plain
975
1127
  * `Question 2 of 5`.
976
1128
  *
977
1129
  * `pips` and `numbers` fall back to `count` past eight questions, where
978
- * neither is countable at a glance any more.
1130
+ * neither is countable at a glance any more. `ring` never does.
979
1131
  */
980
1132
  variant?: QuestionnaireProgressVariant;
981
1133
  /**
@@ -985,6 +1137,74 @@ export interface QuestionnaireProgressProps {
985
1137
  children?: ReactNode | ((state: QuestionnaireProgressState) => ReactNode);
986
1138
  }
987
1139
 
1140
+ const AnimatedCircle = Animated.createAnimatedComponent(Circle);
1141
+
1142
+ /**
1143
+ * How far through the set, as an arc.
1144
+ *
1145
+ * A fraction rather than a count, which is what lets one ring stand for three
1146
+ * questions or thirty: the marks that name each question individually stop
1147
+ * being countable somewhere around eight, and this never has that problem.
1148
+ * What it gives up is any answer to "how many left" — reach for `numbers`
1149
+ * where that matters.
1150
+ *
1151
+ * Drawn with `strokeDasharray` rather than `strokeDashoffset`: a dash the
1152
+ * length of the filled arc and a gap the length of the rest leaves exactly one
1153
+ * visible stroke, and its length is the only number that has to be animated.
1154
+ * The circle is turned back a quarter because a stroke starts at three
1155
+ * o'clock, and an arc that begins there reads as a gauge already part-way
1156
+ * along.
1157
+ */
1158
+ function ProgressRing({ current, total }: { current: number; total: number }) {
1159
+ const reduceMotion = useReducedMotion();
1160
+ const tokens = useCSSVariable(['--color-primary', '--color-border']);
1161
+ // Narrowed on the way out because `useCSSVariable` resolves to a number for
1162
+ // any token that happens to be one.
1163
+ const fillColor = typeof tokens[0] === 'string' ? tokens[0] : 'rgb(120,120,255)';
1164
+ const trackColor = typeof tokens[1] === 'string' ? tokens[1] : 'rgba(128,128,128,0.2)';
1165
+
1166
+ const radius = (RING_SIZE - RING_STROKE) / 2;
1167
+ const circumference = 2 * Math.PI * radius;
1168
+ const centre = RING_SIZE / 2;
1169
+
1170
+ const fraction = total > 0 ? Math.min(Math.max(current / total, 0), 1) : 0;
1171
+ const filled = useSharedValue(fraction);
1172
+
1173
+ useEffect(() => {
1174
+ filled.value = reduceMotion
1175
+ ? fraction
1176
+ : withTiming(fraction, { duration: PIP_DURATION, easing: EASE });
1177
+ }, [fraction, reduceMotion, filled]);
1178
+
1179
+ const arc = useAnimatedProps(() => ({
1180
+ strokeDasharray: [circumference * filled.value, circumference],
1181
+ }));
1182
+
1183
+ return (
1184
+ <Svg width={RING_SIZE} height={RING_SIZE}>
1185
+ <Circle
1186
+ cx={centre}
1187
+ cy={centre}
1188
+ r={radius}
1189
+ stroke={trackColor}
1190
+ strokeWidth={RING_STROKE}
1191
+ fill="none"
1192
+ />
1193
+ <AnimatedCircle
1194
+ animatedProps={arc}
1195
+ cx={centre}
1196
+ cy={centre}
1197
+ r={radius}
1198
+ stroke={fillColor}
1199
+ strokeWidth={RING_STROKE}
1200
+ strokeLinecap="round"
1201
+ fill="none"
1202
+ transform={`rotate(-90 ${centre} ${centre})`}
1203
+ />
1204
+ </Svg>
1205
+ );
1206
+ }
1207
+
988
1208
  /**
989
1209
  * One question's worth of the track. Filled once it has been reached, and the
990
1210
  * one being asked is drawn wider than the rest so the reader's place in the
@@ -1055,7 +1275,7 @@ function ProgressNumber({
1055
1275
  /** Where the reader is in the set, announced as a progress bar. */
1056
1276
  function QuestionnaireProgress({
1057
1277
  className,
1058
- variant = 'pips',
1278
+ variant = 'ring',
1059
1279
  children,
1060
1280
  }: QuestionnaireProgressProps) {
1061
1281
  const { current, total, first, last } = useQuestionnaire('Questionnaire.Progress');
@@ -1064,11 +1284,15 @@ function QuestionnaireProgress({
1064
1284
  /*
1065
1285
  * Marks while they can still be counted, the count itself once they cannot.
1066
1286
  * Twenty of either is a texture rather than a number, and the text says the
1067
- * same thing in less room.
1287
+ * same thing in less room. The ring is exempt: it never claimed to be
1288
+ * countable, so there is nothing for a long set to take away from it.
1068
1289
  */
1069
- const drawable = variant !== 'count' && total > 0 && total <= MAX_PIPS;
1290
+ const drawable =
1291
+ variant !== 'count' && variant !== 'ring' && total > 0 && total <= MAX_PIPS;
1070
1292
 
1071
- const fallback = drawable ? (
1293
+ const fallback = variant === 'ring' ? (
1294
+ <ProgressRing current={current} total={total} />
1295
+ ) : drawable ? (
1072
1296
  <View className={cn('flex-row items-center', variant === 'numbers' ? 'gap-1.5' : 'gap-1')}>
1073
1297
  {Array.from({ length: total }, (_, index) =>
1074
1298
  variant === 'numbers' ? (
@@ -1197,6 +1421,12 @@ export interface QuestionnaireQuestionProps extends ViewProps {
1197
1421
 
1198
1422
  /** The question being asked. */
1199
1423
  function QuestionnaireQuestion({ className, children, ...props }: QuestionnaireQuestionProps) {
1424
+ const { questionOnStrip } = useQuestionnaire('Questionnaire.Question');
1425
+
1426
+ // Drawn on the header strip instead, where an untitled questionnaire uses it
1427
+ // as its label. Rendering here as well would ask the same thing twice.
1428
+ if (questionOnStrip) return null;
1429
+
1200
1430
  return (
1201
1431
  <View className={className} {...props}>
1202
1432
  {textChildren(children, (text) => (