panelui-native 0.92.0 → 0.93.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.
@@ -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,