panelui-native 0.93.0 → 0.94.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.
@@ -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
  *
@@ -168,17 +177,22 @@ export interface QuestionnaireProgressState {
168
177
  last: boolean;
169
178
  }
170
179
  /** How the position is drawn. */
171
- export type QuestionnaireProgressVariant = 'pips' | 'numbers' | 'count';
180
+ export type QuestionnaireProgressVariant = 'ring' | 'pips' | 'numbers' | 'count';
172
181
  export interface QuestionnaireProgressProps {
173
182
  className?: string;
174
183
  /**
184
+ * `ring` is an arc that sweeps round as the reader advances — how far
185
+ * through the set they are, without saying how many questions there are.
186
+ * It is the only one that holds its size and its meaning at any length,
187
+ * which is why it is the default.
188
+ *
175
189
  * `pips` is a bar per question, filled up to the one being asked and widened
176
190
  * on it. `numbers` counts them out instead, which is what you want when the
177
191
  * reader will be sent back to a particular question. `count` is the plain
178
192
  * `Question 2 of 5`.
179
193
  *
180
194
  * `pips` and `numbers` fall back to `count` past eight questions, where
181
- * neither is countable at a glance any more.
195
+ * neither is countable at a glance any more. `ring` never does.
182
196
  */
183
197
  variant?: QuestionnaireProgressVariant;
184
198
  /**
@@ -221,7 +235,7 @@ export interface QuestionnaireQuestionProps extends ViewProps {
221
235
  children?: ReactNode;
222
236
  }
223
237
  /** The question being asked. */
224
- declare function QuestionnaireQuestion({ className, children, ...props }: QuestionnaireQuestionProps): import("react").JSX.Element;
238
+ declare function QuestionnaireQuestion({ className, children, ...props }: QuestionnaireQuestionProps): import("react").JSX.Element | null;
225
239
  declare namespace QuestionnaireQuestion {
226
240
  var displayName: string;
227
241
  }
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../../src/components/questionnaire/index.tsx"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../../src/components/questionnaire/index.tsx"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "panelui-native",
3
- "version": "0.93.0",
3
+ "version": "0.94.0",
4
4
  "description": "High-performance React Native UI components for Expo. Semantic design tokens, powered by Uniwind (Tailwind v4) and Reanimated.",
5
5
  "main": "./lib/module/index.js",
6
6
  "module": "./lib/module/index.js",
@@ -586,6 +586,17 @@ function BottomSheetContent({
586
586
  const pan = useMemo(
587
587
  () =>
588
588
  Gesture.Pan()
589
+ /*
590
+ * A locked sheet does not take the drag at all.
591
+ *
592
+ * Refusing the close is not enough on its own: the drag still moves
593
+ * the sheet, and a controlled caller that declines `onOpenChange`
594
+ * leaves it parked off the bottom of the screen — open as far as the
595
+ * caller is concerned, invisible, and still taking every touch behind
596
+ * a fully transparent backdrop. The backdrop press and the back
597
+ * gesture were already gated; this is the third way out.
598
+ */
599
+ .enabled(dismissible)
589
600
  /*
590
601
  * A drag has to travel before it takes the touch. This detector wraps
591
602
  * the whole sheet, close button included, and an unqualified Pan
@@ -626,9 +637,16 @@ function BottomSheetContent({
626
637
  * your hand from one that merely goes where you put it.
627
638
  */
628
639
  const projected = translateY.value + project(event.velocityY);
640
+ /*
641
+ * `dismissible` again, behind `.enabled()` rather than instead of
642
+ * it. Disabling a gesture does not cancel a touch already in flight,
643
+ * so a sheet locked mid-drag still arrives here — and without this
644
+ * it would fling itself away on the strength of a decision the
645
+ * caller has since revoked. Falling through snaps it back instead.
646
+ */
629
647
  if (
630
- projected > DISMISS_DISTANCE ||
631
- event.velocityY > DISMISS_VELOCITY
648
+ dismissible &&
649
+ (projected > DISMISS_DISTANCE || event.velocityY > DISMISS_VELOCITY)
632
650
  ) {
633
651
  translateY.value = withSpring(
634
652
  screenHeight,
@@ -647,7 +665,15 @@ function BottomSheetContent({
647
665
  // Rebuilt only when one of these changes. Built inline it would be a new
648
666
  // gesture on every render — and the sheet re-renders while it is being
649
667
  // used, each time re-attaching the handler and dropping the live touch.
650
- [close, screenHeight, translateY, scrollGesture, scrollOffset, hasScrollable]
668
+ [
669
+ close,
670
+ dismissible,
671
+ screenHeight,
672
+ translateY,
673
+ scrollGesture,
674
+ scrollOffset,
675
+ hasScrollable,
676
+ ]
651
677
  );
652
678
 
653
679
  const sheetStyle = useAnimatedStyle(() => ({
@@ -165,6 +165,20 @@ const AXIS_TITLE_HEIGHT = 18;
165
165
  /** Gap between a quadrant's caption and the corner it is written in. */
166
166
  const QUADRANT_LABEL_INSET = 6;
167
167
 
168
+ /**
169
+ * How strongly the overlays draw: the quadrant crosshair, and the size key's
170
+ * rings.
171
+ *
172
+ * Both are furniture rather than data, and both were faint enough to be missed
173
+ * — the crosshair at 0.4 and the rings at 0.6, on a token that is already a
174
+ * mid-grey chosen to stay behind the circles. The restraint has to come from
175
+ * the token, not from a second reduction on top of it, or the mark is one
176
+ * nobody finds. `Trend` has drawn at 0.7 all along and reads correctly in both
177
+ * themes, so that is the number.
178
+ */
179
+ const CROSSHAIR_OPACITY = 0.7;
180
+ const KEY_RING_OPACITY = 0.85;
181
+
168
182
  /** Column the size key's values are written in, beside its circles. */
169
183
  const SIZE_KEY_LABEL_WIDTH = 44;
170
184
 
@@ -1307,6 +1321,12 @@ function BubbleChartQuadrants({
1307
1321
  />
1308
1322
  </>
1309
1323
  ) : null}
1324
+ {/*
1325
+ The same weight `Trend` draws at, because it is the same kind of mark:
1326
+ a reference laid over the field rather than a reading taken from it.
1327
+ At 0.4 of a mid-grey the split was a line you had to already know was
1328
+ there, which leaves four labelled corners and nothing dividing them.
1329
+ */}
1310
1330
  <Animated.View
1311
1331
  style={[
1312
1332
  {
@@ -1315,7 +1335,7 @@ function BubbleChartQuadrants({
1315
1335
  height: plot.height,
1316
1336
  width: 1,
1317
1337
  backgroundColor: stroke,
1318
- opacity: 0.4,
1338
+ opacity: CROSSHAIR_OPACITY,
1319
1339
  },
1320
1340
  verticalStyle,
1321
1341
  ]}
@@ -1328,7 +1348,7 @@ function BubbleChartQuadrants({
1328
1348
  width: plot.width,
1329
1349
  height: 1,
1330
1350
  backgroundColor: stroke,
1331
- opacity: 0.4,
1351
+ opacity: CROSSHAIR_OPACITY,
1332
1352
  },
1333
1353
  horizontalStyle,
1334
1354
  ]}
@@ -1341,7 +1361,7 @@ function BubbleChartQuadrants({
1341
1361
  {labels?.topLeft ? (
1342
1362
  <Text
1343
1363
  size="xs"
1344
- muted
1364
+ weight="medium"
1345
1365
  numberOfLines={1}
1346
1366
  style={{
1347
1367
  ...corner,
@@ -1355,7 +1375,7 @@ function BubbleChartQuadrants({
1355
1375
  {labels?.topRight ? (
1356
1376
  <Text
1357
1377
  size="xs"
1358
- muted
1378
+ weight="medium"
1359
1379
  numberOfLines={1}
1360
1380
  style={{
1361
1381
  ...corner,
@@ -1370,7 +1390,7 @@ function BubbleChartQuadrants({
1370
1390
  {labels?.bottomLeft ? (
1371
1391
  <Text
1372
1392
  size="xs"
1373
- muted
1393
+ weight="medium"
1374
1394
  numberOfLines={1}
1375
1395
  style={{
1376
1396
  ...corner,
@@ -1384,7 +1404,7 @@ function BubbleChartQuadrants({
1384
1404
  {labels?.bottomRight ? (
1385
1405
  <Text
1386
1406
  size="xs"
1387
- muted
1407
+ weight="medium"
1388
1408
  numberOfLines={1}
1389
1409
  style={{
1390
1410
  ...corner,
@@ -1467,7 +1487,7 @@ function BubbleChartSizeKey({
1467
1487
  className={cn(className)}
1468
1488
  >
1469
1489
  {label ? (
1470
- <Text size="xs" muted numberOfLines={1}>
1490
+ <Text size="xs" weight="medium" numberOfLines={1}>
1471
1491
  {label}
1472
1492
  </Text>
1473
1493
  ) : null}
@@ -1486,7 +1506,7 @@ function BubbleChartSizeKey({
1486
1506
  borderRadius: step.r,
1487
1507
  borderWidth: 1,
1488
1508
  borderColor: stroke,
1489
- opacity: 0.6,
1509
+ opacity: KEY_RING_OPACITY,
1490
1510
  }}
1491
1511
  />
1492
1512
  ))}
@@ -1494,7 +1514,7 @@ function BubbleChartSizeKey({
1494
1514
  <Text
1495
1515
  key={`v${step.value}`}
1496
1516
  size="xs"
1497
- muted
1517
+ weight="medium"
1498
1518
  numberOfLines={1}
1499
1519
  style={{
1500
1520
  position: 'absolute',
@@ -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) => (