panelui-native 0.92.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.
Files changed (29) hide show
  1. package/lib/module/components/bottom-sheet/index.js +19 -2
  2. package/lib/module/components/bottom-sheet/index.js.map +1 -1
  3. package/lib/module/components/bubble-chart/index.js +23 -9
  4. package/lib/module/components/bubble-chart/index.js.map +1 -1
  5. package/lib/module/components/frame/index.js +178 -34
  6. package/lib/module/components/frame/index.js.map +1 -1
  7. package/lib/module/components/page-header/index.js +577 -0
  8. package/lib/module/components/page-header/index.js.map +1 -0
  9. package/lib/module/components/questionnaire/index.js +242 -22
  10. package/lib/module/components/questionnaire/index.js.map +1 -1
  11. package/lib/module/index.js +1 -0
  12. package/lib/module/index.js.map +1 -1
  13. package/lib/typescript/src/components/bottom-sheet/index.d.ts.map +1 -1
  14. package/lib/typescript/src/components/bubble-chart/index.d.ts.map +1 -1
  15. package/lib/typescript/src/components/frame/index.d.ts +19 -2
  16. package/lib/typescript/src/components/frame/index.d.ts.map +1 -1
  17. package/lib/typescript/src/components/page-header/index.d.ts +278 -0
  18. package/lib/typescript/src/components/page-header/index.d.ts.map +1 -0
  19. package/lib/typescript/src/components/questionnaire/index.d.ts +19 -5
  20. package/lib/typescript/src/components/questionnaire/index.d.ts.map +1 -1
  21. package/lib/typescript/src/index.d.ts +2 -1
  22. package/lib/typescript/src/index.d.ts.map +1 -1
  23. package/package.json +1 -1
  24. package/src/components/bottom-sheet/index.tsx +29 -3
  25. package/src/components/bubble-chart/index.tsx +29 -9
  26. package/src/components/frame/index.tsx +180 -29
  27. package/src/components/page-header/index.tsx +674 -0
  28. package/src/components/questionnaire/index.tsx +249 -19
  29. package/src/index.ts +17 -0
@@ -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',
@@ -50,6 +50,17 @@
50
50
  * </Frame>
51
51
  * ```
52
52
  *
53
+ * `inset` is the other way to nest the two. The panel floats clear of the
54
+ * shell on all four sides rather than sitting flush against three, and the band
55
+ * left around it carries `Frame.Footer`. The band is a recess: the shell is the
56
+ * popover surface with `--color-inset` laid over it rather than a colour of its
57
+ * own, so it always comes out darker than the panel it holds. The surface
58
+ * ladder cannot do that job — it runs darker in a light theme and lighter in a
59
+ * dark one, and a recess has to read the same way in both.
60
+ *
61
+ * There is no shadow under it. A recessed band and a drop shadow are opposite
62
+ * claims about where a surface sits, and this one is set into the page.
63
+ *
53
64
  * The panel draws the hairlines between its own rows. React Native has no
54
65
  * `:first-child`, so the alternative is every caller writing
55
66
  * `divided={index > 0}` on every row and getting it wrong once.
@@ -83,27 +94,64 @@ import { ChevronRightIcon } from '../../icons';
83
94
  import { Text, type TextProps, textChildren } from '../../primitives/text';
84
95
  import { cn } from '../../utils/cn';
85
96
 
97
+ /**
98
+ * The `inset` shell's geometry. Numbers rather than classes, because an
99
+ * arbitrary Tailwind value a running dev server has not already compiled turns
100
+ * into nothing at all — no error, no warning, the corner simply squares off.
101
+ *
102
+ * The panel's radius is the shell's less the shell's padding, so the two curves
103
+ * are concentric. A panel radius that ignored the band would leave a crescent
104
+ * of shell thicker at the corners than along the sides.
105
+ */
106
+ const SHELL_PADDING = 8;
107
+ const SHELL_RADIUS = 38;
108
+ const PANEL_RADIUS = SHELL_RADIUS - SHELL_PADDING;
109
+ const FOOTER_INSET = 26;
110
+ const FOOTER_GAP = 16;
111
+
112
+ /** What the band does to an action put in it: equal width, and a full pill. */
113
+ const FOOTER_ACTION = 'h-11 flex-1 rounded-full';
114
+
86
115
  const frameVariants = tv({
87
116
  slots: {
88
117
  root: '',
89
- // Flush left, right and bottom: no side or bottom border, because the
90
- // shell's own edge is already there, and no bottom radius, because the
91
- // shell clips it. Only the top corners and the rule under the header are
92
- // the panel's to draw.
93
- panel: 'overflow-hidden rounded-t-2xl border-t border-border bg-card',
118
+ recess: 'absolute inset-0 bg-inset',
119
+ panel: 'overflow-hidden bg-card',
120
+ header: 'flex-row items-center justify-between gap-3 px-4 pb-3 pt-2.5',
121
+ footer: 'flex-row items-center gap-2 px-4 pb-3.5 pt-3',
94
122
  },
95
123
  variants: {
96
124
  variant: {
97
125
  // `overflow-hidden` is load-bearing — it is what makes the panel's
98
126
  // bottom corners take the shell's radius instead of squaring off
99
127
  // against it.
128
+ //
129
+ // The panel is flush left, right and bottom: no side or bottom border,
130
+ // because the shell's own edge is already there, and no bottom radius,
131
+ // because the shell clips it. Only the top corners and the rule under
132
+ // the header are the panel's to draw.
100
133
  default: {
101
134
  root: 'overflow-hidden rounded-3xl border border-border bg-surface',
135
+ panel: 'rounded-t-2xl border-t border-border',
102
136
  },
103
137
  // No shell: the panel is the whole widget. For a Frame nested inside a
104
138
  // card that already draws a border, where the shell's own edge sitting
105
139
  // just inside it reads as a double line.
106
- plain: { root: '', panel: 'rounded-3xl border border-border' },
140
+ plain: {
141
+ root: '',
142
+ panel: 'rounded-3xl border border-border',
143
+ // Nothing to be held in from — the footer lines up with the panel.
144
+ footer: 'px-0',
145
+ },
146
+ // The panel floats inside the shell on all four sides instead of sitting
147
+ // flush against three of them, and the band around it is a recess rather
148
+ // than a lighter tray.
149
+ inset: {
150
+ root: 'overflow-hidden bg-popover',
151
+ panel: 'bg-popover',
152
+ header: 'px-3 pb-2.5 pt-1',
153
+ footer: 'gap-3.5 p-0',
154
+ },
107
155
  },
108
156
  },
109
157
  defaultVariants: {
@@ -111,7 +159,7 @@ const frameVariants = tv({
111
159
  },
112
160
  });
113
161
 
114
- export type FrameVariant = 'default' | 'plain';
162
+ export type FrameVariant = 'default' | 'plain' | 'inset';
115
163
 
116
164
  /**
117
165
  * True inside a `Frame.Content`. The header's caption and a row's title are the
@@ -121,6 +169,14 @@ export type FrameVariant = 'default' | 'plain';
121
169
  */
122
170
  const FrameSlotContext = createContext(false);
123
171
 
172
+ /**
173
+ * The root's variant, for the parts that draw differently under each one. The
174
+ * panel, the header and the footer all need it, and none of them can be told
175
+ * directly — a caller writes `<Frame variant="inset">` once and expects the
176
+ * shape to follow.
177
+ */
178
+ const FrameVariantContext = createContext<FrameVariant>('default');
179
+
124
180
  export interface FrameProps extends ViewProps {
125
181
  className?: string;
126
182
  }
@@ -128,15 +184,35 @@ export interface FrameProps extends ViewProps {
128
184
  export interface FrameRootProps extends FrameProps {
129
185
  /**
130
186
  * `plain` drops the outer shell so the panel is the widget — for a Frame
131
- * inside a container that already draws its own border.
187
+ * inside a container that already draws its own border. `inset` sets the
188
+ * panel into a recessed band on all four sides instead, and gives
189
+ * `Frame.Footer` somewhere to sit.
132
190
  */
133
191
  variant?: FrameVariant;
134
192
  }
135
193
 
136
194
  const FrameRoot = forwardRef<View, FrameRootProps>(
137
- ({ className, variant, ...props }, ref) => (
138
- <View ref={ref} className={frameVariants({ variant }).root({ className })} {...props} />
139
- )
195
+ ({ className, variant = 'default', children, style, ...props }, ref) => {
196
+ const slots = frameVariants({ variant });
197
+ const inset = variant === 'inset';
198
+ return (
199
+ <View
200
+ {...props}
201
+ ref={ref}
202
+ className={slots.root({ className })}
203
+ style={
204
+ inset
205
+ ? [{ borderRadius: SHELL_RADIUS, padding: SHELL_PADDING }, style]
206
+ : style
207
+ }
208
+ >
209
+ {inset ? <View pointerEvents="none" className={slots.recess()} /> : null}
210
+ <FrameVariantContext.Provider value={variant}>
211
+ {children}
212
+ </FrameVariantContext.Provider>
213
+ </View>
214
+ );
215
+ }
140
216
  );
141
217
  FrameRoot.displayName = 'Frame';
142
218
 
@@ -150,18 +226,22 @@ export interface FrameHeaderProps extends FrameProps {
150
226
  * when you want a description underneath.
151
227
  */
152
228
  const FrameHeader = forwardRef<View, FrameHeaderProps>(
153
- ({ className, ...props }, ref) => (
154
- <View
155
- ref={ref}
156
- className={cn(
157
- // `min-w-0` on nothing here — the title itself takes the flexible side,
158
- // so a long one truncates instead of shoving the action off the edge.
159
- 'flex-row items-center justify-between gap-3 px-4 pb-3 pt-2.5',
160
- className
161
- )}
162
- {...props}
163
- />
164
- )
229
+ ({ className, ...props }, ref) => {
230
+ // `min-w-0` on nothing here — the title itself takes the flexible side, so
231
+ // a long one truncates instead of shoving the action off the edge.
232
+ //
233
+ // The padding follows the variant: under `inset` the shell already holds
234
+ // everything in by its own padding, so repeating the full inset here would
235
+ // set the title further from the edge than the panel below it.
236
+ const variant = useContext(FrameVariantContext);
237
+ return (
238
+ <View
239
+ {...props}
240
+ ref={ref}
241
+ className={frameVariants({ variant }).header({ className })}
242
+ />
243
+ );
244
+ }
165
245
  );
166
246
  FrameHeader.displayName = 'Frame.Header';
167
247
 
@@ -336,14 +416,23 @@ export interface FramePanelProps extends FrameProps {
336
416
 
337
417
  /**
338
418
  * The card holding the frame's content — flush to the shell on three sides,
339
- * with the header strip above it.
419
+ * with the header strip above it. Under `inset` it floats clear of all four
420
+ * instead, at the radius that keeps it concentric with the shell.
340
421
  */
341
422
  const FramePanel = forwardRef<View, FramePanelProps>(
342
- ({ className, dividers = true, children, ...props }, ref) => (
343
- <View ref={ref} className={frameVariants().panel({ className })} {...props}>
344
- {dividers ? divideChildren(children) : children}
345
- </View>
346
- )
423
+ ({ className, dividers = true, children, style, ...props }, ref) => {
424
+ const variant = useContext(FrameVariantContext);
425
+ return (
426
+ <View
427
+ {...props}
428
+ ref={ref}
429
+ className={frameVariants({ variant }).panel({ className })}
430
+ style={variant === 'inset' ? [{ borderRadius: PANEL_RADIUS }, style] : style}
431
+ >
432
+ {dividers ? divideChildren(children) : children}
433
+ </View>
434
+ );
435
+ }
347
436
  );
348
437
  FramePanel.displayName = 'Frame.Panel';
349
438
 
@@ -455,6 +544,67 @@ const FrameSection = forwardRef<View, FrameSectionProps>(
455
544
  );
456
545
  FrameSection.displayName = 'Frame.Section';
457
546
 
547
+ export interface FrameFooterProps extends FrameProps {
548
+ children?: ReactNode;
549
+ }
550
+
551
+ /**
552
+ * Shapes an `inset` footer's actions into the band's pills.
553
+ *
554
+ * The band is a row of equal decisions, so the actions are equal widths and
555
+ * full circles rather than whatever radius each one arrived with. Doing it
556
+ * here rather than asking every caller for three classes is the difference
557
+ * between a variant that looks a certain way and one that can be made to.
558
+ *
559
+ * The caller's own `className` is merged last, so any of it can still be
560
+ * overridden — a trailing icon button that should stay square, say.
561
+ */
562
+ function pillChildren(children: ReactNode) {
563
+ return Children.map(children, (child) => {
564
+ if (!isValidElement<{ className?: string }>(child)) return child;
565
+ return cloneElement(child, {
566
+ className: cn(FOOTER_ACTION, child.props.className),
567
+ });
568
+ });
569
+ }
570
+
571
+ /**
572
+ * The row of actions under the panel — what somebody does with the widget,
573
+ * rather than more of what it says.
574
+ *
575
+ * Under `inset` it sits in the band, held further in than the panel is. A row
576
+ * running the full width of the shell reads as another edge of it rather than
577
+ * as things to press. Under the other two variants the panel stops being flush
578
+ * at the bottom, which is what having a footer means there.
579
+ */
580
+ const FrameFooter = forwardRef<View, FrameFooterProps>(
581
+ ({ className, style, children, ...props }, ref) => {
582
+ const variant = useContext(FrameVariantContext);
583
+ const inset = variant === 'inset';
584
+ return (
585
+ <View
586
+ {...props}
587
+ ref={ref}
588
+ className={frameVariants({ variant }).footer({ className })}
589
+ style={
590
+ inset
591
+ ? [
592
+ {
593
+ marginTop: FOOTER_GAP,
594
+ marginHorizontal: FOOTER_INSET - SHELL_PADDING,
595
+ },
596
+ style,
597
+ ]
598
+ : style
599
+ }
600
+ >
601
+ {inset ? pillChildren(children) : children}
602
+ </View>
603
+ );
604
+ }
605
+ );
606
+ FrameFooter.displayName = 'Frame.Footer';
607
+
458
608
  /** Parts the panel divides. Declared after them, since it holds references. */
459
609
  const DIVIDABLE = new Set<unknown>([FrameRow, FrameSection]);
460
610
 
@@ -464,6 +614,7 @@ export const Frame = Object.assign(FrameRoot, {
464
614
  Action: FrameAction,
465
615
  Description: FrameDescription,
466
616
  Panel: FramePanel,
617
+ Footer: FrameFooter,
467
618
  Section: FrameSection,
468
619
  Row: FrameRow,
469
620
  Media: FrameMedia,