panelui-native 0.7.0 → 0.12.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 (161) hide show
  1. package/README.md +5 -3
  2. package/lib/module/components/attachment/index.js +219 -0
  3. package/lib/module/components/attachment/index.js.map +1 -0
  4. package/lib/module/components/bottom-sheet/index.js +53 -3
  5. package/lib/module/components/bottom-sheet/index.js.map +1 -1
  6. package/lib/module/components/button/index.js +19 -1
  7. package/lib/module/components/button/index.js.map +1 -1
  8. package/lib/module/components/dialog/index.js +6 -2
  9. package/lib/module/components/dialog/index.js.map +1 -1
  10. package/lib/module/components/empty-state/index.js +94 -17
  11. package/lib/module/components/empty-state/index.js.map +1 -1
  12. package/lib/module/components/frame/index.js +128 -15
  13. package/lib/module/components/frame/index.js.map +1 -1
  14. package/lib/module/components/input/index.js +107 -15
  15. package/lib/module/components/input/index.js.map +1 -1
  16. package/lib/module/components/line-chart/index.js +1024 -0
  17. package/lib/module/components/line-chart/index.js.map +1 -0
  18. package/lib/module/components/marker/index.js +190 -0
  19. package/lib/module/components/marker/index.js.map +1 -0
  20. package/lib/module/components/message/index.js +24 -8
  21. package/lib/module/components/message/index.js.map +1 -1
  22. package/lib/module/components/message-scroller/index.js +451 -0
  23. package/lib/module/components/message-scroller/index.js.map +1 -0
  24. package/lib/module/components/popover/index.js +511 -0
  25. package/lib/module/components/popover/index.js.map +1 -0
  26. package/lib/module/components/progress/index.js +52 -2
  27. package/lib/module/components/progress/index.js.map +1 -1
  28. package/lib/module/components/radio-group/index.js +132 -17
  29. package/lib/module/components/radio-group/index.js.map +1 -1
  30. package/lib/module/components/section-rail/index.js +355 -0
  31. package/lib/module/components/section-rail/index.js.map +1 -0
  32. package/lib/module/components/select/index.js +245 -56
  33. package/lib/module/components/select/index.js.map +1 -1
  34. package/lib/module/components/separator/index.js +91 -0
  35. package/lib/module/components/separator/index.js.map +1 -0
  36. package/lib/module/components/slider/index.js +355 -0
  37. package/lib/module/components/slider/index.js.map +1 -0
  38. package/lib/module/components/surface/index.js +21 -2
  39. package/lib/module/components/surface/index.js.map +1 -1
  40. package/lib/module/components/switch/index.js +10 -1
  41. package/lib/module/components/switch/index.js.map +1 -1
  42. package/lib/module/components/tabs/index.js +186 -16
  43. package/lib/module/components/tabs/index.js.map +1 -1
  44. package/lib/module/components/toggle-button/index.js +297 -0
  45. package/lib/module/components/toggle-button/index.js.map +1 -0
  46. package/lib/module/hooks/index.js +1 -0
  47. package/lib/module/hooks/index.js.map +1 -1
  48. package/lib/module/hooks/use-keyboard-avoidance.js +57 -8
  49. package/lib/module/hooks/use-keyboard-avoidance.js.map +1 -1
  50. package/lib/module/hooks/use-scroll-sections.js +89 -0
  51. package/lib/module/hooks/use-scroll-sections.js.map +1 -0
  52. package/lib/module/icons/index.js +69 -0
  53. package/lib/module/icons/index.js.map +1 -1
  54. package/lib/module/index.js +11 -2
  55. package/lib/module/index.js.map +1 -1
  56. package/lib/module/native/index.js.map +1 -1
  57. package/lib/module/primitives/keyboard-avoider.js +2 -0
  58. package/lib/module/primitives/keyboard-avoider.js.map +1 -1
  59. package/lib/module/primitives/scrim.js +72 -0
  60. package/lib/module/primitives/scrim.js.map +1 -0
  61. package/lib/module/utils/haptics.js +57 -0
  62. package/lib/module/utils/haptics.js.map +1 -0
  63. package/lib/typescript/src/components/attachment/index.d.ts +93 -0
  64. package/lib/typescript/src/components/attachment/index.d.ts.map +1 -0
  65. package/lib/typescript/src/components/bottom-sheet/index.d.ts +6 -1
  66. package/lib/typescript/src/components/bottom-sheet/index.d.ts.map +1 -1
  67. package/lib/typescript/src/components/button/index.d.ts +9 -4
  68. package/lib/typescript/src/components/button/index.d.ts.map +1 -1
  69. package/lib/typescript/src/components/dialog/index.d.ts +7 -1
  70. package/lib/typescript/src/components/dialog/index.d.ts.map +1 -1
  71. package/lib/typescript/src/components/empty-state/index.d.ts +152 -2
  72. package/lib/typescript/src/components/empty-state/index.d.ts.map +1 -1
  73. package/lib/typescript/src/components/frame/index.d.ts +47 -7
  74. package/lib/typescript/src/components/frame/index.d.ts.map +1 -1
  75. package/lib/typescript/src/components/input/index.d.ts +113 -2
  76. package/lib/typescript/src/components/input/index.d.ts.map +1 -1
  77. package/lib/typescript/src/components/line-chart/index.d.ts +247 -0
  78. package/lib/typescript/src/components/line-chart/index.d.ts.map +1 -0
  79. package/lib/typescript/src/components/marker/index.d.ts +137 -0
  80. package/lib/typescript/src/components/marker/index.d.ts.map +1 -0
  81. package/lib/typescript/src/components/message/index.d.ts +8 -1
  82. package/lib/typescript/src/components/message/index.d.ts.map +1 -1
  83. package/lib/typescript/src/components/message-scroller/index.d.ts +150 -0
  84. package/lib/typescript/src/components/message-scroller/index.d.ts.map +1 -0
  85. package/lib/typescript/src/components/popover/index.d.ts +136 -0
  86. package/lib/typescript/src/components/popover/index.d.ts.map +1 -0
  87. package/lib/typescript/src/components/progress/index.d.ts +33 -0
  88. package/lib/typescript/src/components/progress/index.d.ts.map +1 -1
  89. package/lib/typescript/src/components/radio-group/index.d.ts +39 -0
  90. package/lib/typescript/src/components/radio-group/index.d.ts.map +1 -1
  91. package/lib/typescript/src/components/section-rail/index.d.ts +115 -0
  92. package/lib/typescript/src/components/section-rail/index.d.ts.map +1 -0
  93. package/lib/typescript/src/components/select/index.d.ts +46 -7
  94. package/lib/typescript/src/components/select/index.d.ts.map +1 -1
  95. package/lib/typescript/src/components/separator/index.d.ts +47 -0
  96. package/lib/typescript/src/components/separator/index.d.ts.map +1 -0
  97. package/lib/typescript/src/components/slider/index.d.ts +203 -0
  98. package/lib/typescript/src/components/slider/index.d.ts.map +1 -0
  99. package/lib/typescript/src/components/surface/index.d.ts +51 -2
  100. package/lib/typescript/src/components/surface/index.d.ts.map +1 -1
  101. package/lib/typescript/src/components/switch/index.d.ts.map +1 -1
  102. package/lib/typescript/src/components/tabs/index.d.ts +46 -4
  103. package/lib/typescript/src/components/tabs/index.d.ts.map +1 -1
  104. package/lib/typescript/src/components/toggle-button/index.d.ts +244 -0
  105. package/lib/typescript/src/components/toggle-button/index.d.ts.map +1 -0
  106. package/lib/typescript/src/hooks/index.d.ts +1 -0
  107. package/lib/typescript/src/hooks/index.d.ts.map +1 -1
  108. package/lib/typescript/src/hooks/use-keyboard-avoidance.d.ts +10 -1
  109. package/lib/typescript/src/hooks/use-keyboard-avoidance.d.ts.map +1 -1
  110. package/lib/typescript/src/hooks/use-scroll-sections.d.ts +31 -0
  111. package/lib/typescript/src/hooks/use-scroll-sections.d.ts.map +1 -0
  112. package/lib/typescript/src/icons/index.d.ts +6 -0
  113. package/lib/typescript/src/icons/index.d.ts.map +1 -1
  114. package/lib/typescript/src/index.d.ts +13 -4
  115. package/lib/typescript/src/index.d.ts.map +1 -1
  116. package/lib/typescript/src/native/index.d.ts +16 -1
  117. package/lib/typescript/src/native/index.d.ts.map +1 -1
  118. package/lib/typescript/src/primitives/keyboard-avoider.d.ts +8 -1
  119. package/lib/typescript/src/primitives/keyboard-avoider.d.ts.map +1 -1
  120. package/lib/typescript/src/primitives/scrim.d.ts +21 -0
  121. package/lib/typescript/src/primitives/scrim.d.ts.map +1 -0
  122. package/lib/typescript/src/utils/haptics.d.ts +12 -0
  123. package/lib/typescript/src/utils/haptics.d.ts.map +1 -0
  124. package/package.json +9 -1
  125. package/src/components/attachment/index.tsx +279 -0
  126. package/src/components/bottom-sheet/index.tsx +58 -2
  127. package/src/components/button/index.tsx +26 -5
  128. package/src/components/dialog/index.tsx +10 -1
  129. package/src/components/empty-state/index.tsx +60 -14
  130. package/src/components/frame/index.tsx +175 -29
  131. package/src/components/input/index.tsx +122 -16
  132. package/src/components/line-chart/index.tsx +1224 -0
  133. package/src/components/marker/index.tsx +194 -0
  134. package/src/components/message/index.tsx +29 -5
  135. package/src/components/message-scroller/index.tsx +574 -0
  136. package/src/components/popover/index.tsx +624 -0
  137. package/src/components/progress/index.tsx +81 -1
  138. package/src/components/radio-group/index.tsx +156 -18
  139. package/src/components/section-rail/index.tsx +459 -0
  140. package/src/components/select/index.tsx +265 -52
  141. package/src/components/separator/index.tsx +97 -0
  142. package/src/components/slider/index.tsx +371 -0
  143. package/src/components/surface/index.tsx +29 -3
  144. package/src/components/switch/index.tsx +8 -1
  145. package/src/components/tabs/index.tsx +208 -28
  146. package/src/components/toggle-button/index.tsx +374 -0
  147. package/src/hooks/index.ts +5 -0
  148. package/src/hooks/use-keyboard-avoidance.ts +73 -7
  149. package/src/hooks/use-scroll-sections.ts +127 -0
  150. package/src/icons/index.tsx +48 -0
  151. package/src/index.ts +97 -6
  152. package/src/native/index.ts +13 -1
  153. package/src/primitives/keyboard-avoider.tsx +9 -1
  154. package/src/primitives/scrim.tsx +95 -0
  155. package/src/utils/haptics.ts +63 -0
  156. package/theme.css +64 -2
  157. package/lib/module/components/inline-select/index.js +0 -284
  158. package/lib/module/components/inline-select/index.js.map +0 -1
  159. package/lib/typescript/src/components/inline-select/index.d.ts +0 -48
  160. package/lib/typescript/src/components/inline-select/index.d.ts.map +0 -1
  161. package/src/components/inline-select/index.tsx +0 -332
@@ -10,6 +10,7 @@ import {
10
10
  type ReactNode,
11
11
  } from 'react';
12
12
  import { Pressable, useWindowDimensions, View, type ViewProps } from 'react-native';
13
+ import { useCSSVariable } from 'uniwind';
13
14
  import { Gesture, GestureDetector } from 'react-native-gesture-handler';
14
15
  import Animated, {
15
16
  FadeIn,
@@ -24,6 +25,7 @@ import Animated, {
24
25
  } from 'react-native-reanimated';
25
26
  import { useSafeAreaInsets } from 'react-native-safe-area-context';
26
27
  import { getNativeUI } from '../../native';
28
+ import { XIcon } from '../../icons';
27
29
  import { Portal } from '../../primitives/portal';
28
30
  import { cn } from '../../utils/cn';
29
31
 
@@ -69,6 +71,39 @@ export interface BottomSheetProps {
69
71
  snapPoints?: ('half' | 'full' | { fraction: number } | { height: number })[];
70
72
  }
71
73
 
74
+ /**
75
+ * Roughly how tall the platform will make the sheet at a given detent, as a
76
+ * fraction of the screen. Approximate on purpose — it is used as a floor for
77
+ * the content, not as the sheet's real height, which the platform owns.
78
+ */
79
+ const DETENT_FRACTION = { half: 0.5, full: 0.9 } as const;
80
+
81
+ /**
82
+ * The height the content should at least fill for a given set of detents.
83
+ *
84
+ * Without this the hosted content sizes to itself and the platform centres
85
+ * that smaller box inside the taller sheet, which is why a short sheet shows
86
+ * its content floating in the middle. Filling the detent leaves nothing to
87
+ * centre, so the content sits where it was written: at the top.
88
+ */
89
+ function detentFloor(
90
+ snapPoints: BottomSheetProps['snapPoints'],
91
+ screenHeight: number
92
+ ): number | undefined {
93
+ if (!snapPoints?.length) return undefined;
94
+
95
+ // The sheet opens at its first detent, so that is the one to fill.
96
+ const first = snapPoints[0];
97
+ if (first === 'half' || first === 'full') {
98
+ return screenHeight * DETENT_FRACTION[first];
99
+ }
100
+ if (typeof first === 'object' && 'fraction' in first) {
101
+ return screenHeight * first.fraction;
102
+ }
103
+ if (typeof first === 'object' && 'height' in first) return first.height;
104
+ return undefined;
105
+ }
106
+
72
107
  /**
73
108
  * Set by the root so Content knows the platform is drawing the sheet, and with
74
109
  * which detents. Null means the styled sheet renders.
@@ -138,12 +173,18 @@ export interface BottomSheetContentProps extends ViewProps {
138
173
  className?: string;
139
174
  /** Tap on the backdrop closes the sheet. Default true. */
140
175
  dismissible?: boolean;
176
+ /**
177
+ * Show a close button in the top-right corner. On by default for the styled
178
+ * sheet; ignored by the native sheet, which has its own dismiss affordances.
179
+ */
180
+ showClose?: boolean;
141
181
  children?: ReactNode;
142
182
  }
143
183
 
144
184
  function BottomSheetContent({
145
185
  className,
146
186
  dismissible = true,
187
+ showClose = true,
147
188
  children,
148
189
  ...props
149
190
  }: BottomSheetContentProps) {
@@ -153,6 +194,7 @@ function BottomSheetContent({
153
194
  const { height: screenHeight, width: screenWidth } = useWindowDimensions();
154
195
  const insets = useSafeAreaInsets();
155
196
  const translateY = useSharedValue(0);
197
+ const closeTint = useCSSVariable('--color-muted-foreground');
156
198
 
157
199
  const close = useCallback(() => setOpen(false), [setOpen]);
158
200
 
@@ -200,8 +242,10 @@ function BottomSheetContent({
200
242
  {...props}
201
243
  className={cn(
202
244
  // The platform draws the container, but it hands us a bare
203
- // box — padding and safe-area are still ours.
204
- 'gap-2 px-5 pb-2 pt-1',
245
+ // box — padding and safe-area are still ours. The top
246
+ // padding has to clear the platform's grabber, which sits
247
+ // inside the sheet rather than above the content.
248
+ 'justify-start gap-2 px-5 pb-2 pt-5',
205
249
  className
206
250
  )}
207
251
  style={{
@@ -210,6 +254,7 @@ function BottomSheetContent({
210
254
  // against, so `100%` measures against nothing and the
211
255
  // content lays out wider than the sheet it sits in.
212
256
  width: screenWidth,
257
+ minHeight: detentFloor(nativeSheet.snapPoints, screenHeight),
213
258
  paddingBottom: Math.max(insets.bottom, 16),
214
259
  }}
215
260
  >
@@ -256,6 +301,17 @@ function BottomSheetContent({
256
301
  <View className="mb-3 self-center">
257
302
  <View className="h-1 w-10 rounded-full bg-muted-foreground/30" />
258
303
  </View>
304
+ {showClose ? (
305
+ <Pressable
306
+ accessibilityRole="button"
307
+ accessibilityLabel="Close"
308
+ onPress={close}
309
+ hitSlop={8}
310
+ className="absolute right-4 top-3 h-8 w-8 items-center justify-center rounded-full bg-muted active:opacity-70"
311
+ >
312
+ <XIcon size={16} color={typeof closeTint === 'string' ? closeTint : undefined} />
313
+ </Pressable>
314
+ ) : null}
259
315
  {children}
260
316
  </Animated.View>
261
317
  </GestureDetector>
@@ -110,14 +110,35 @@ export interface ButtonProps
110
110
  * optional `@expo/ui` package; without it this prop does nothing.
111
111
  *
112
112
  * **Theme tokens do not apply** — the platform draws the button, so
113
- * `className`, `size`, `fullWidth`, `startContent`, `endContent` and
114
- * `loading` are all ignored. `variant` maps onto the nearest platform
115
- * style: `primary`/`destructive` → filled, `outline` → outlined, everything
116
- * else → text.
113
+ * `className`, `fullWidth`, `startContent`, `endContent` and `loading` are
114
+ * all ignored. `variant` maps onto the nearest platform style:
115
+ * `primary`/`destructive` → filled, `outline` → outlined, everything else
116
+ * → text; `size` sets the height.
117
+ *
118
+ * A native button **fills the width it is given**, because the host is told
119
+ * its size rather than left to measure the platform's content — which is
120
+ * what made it collapse and then jump on first press. Put it in a row to
121
+ * share the width, or in a narrower parent to shrink it.
117
122
  */
118
123
  native?: boolean;
119
124
  }
120
125
 
126
+ /**
127
+ * Height given to the native host, matching the styled scale above.
128
+ *
129
+ * The host is told its size rather than asked to work it out. `matchContents`
130
+ * measures the platform's own content a frame late and again when that content
131
+ * changes — so the button renders at nothing, collapses against whatever sits
132
+ * above it, and jolts into place on the first press. Width comes from ordinary
133
+ * layout instead, which is why a native button fills its container.
134
+ */
135
+ const NATIVE_HEIGHT: Record<NonNullable<ButtonVariantProps['size']>, number> = {
136
+ sm: 36,
137
+ md: 44,
138
+ lg: 48,
139
+ icon: 44,
140
+ };
141
+
121
142
  /** PanelUI variants mapped onto the platform button styles. */
122
143
  const NATIVE_VARIANT: Record<
123
144
  NonNullable<ButtonVariantProps['variant']>,
@@ -178,7 +199,7 @@ export const Button = forwardRef<View, ButtonProps>(
178
199
  const isStringLabel = typeof children === 'string';
179
200
 
180
201
  return (
181
- <Host matchContents>
202
+ <Host style={{ height: NATIVE_HEIGHT[size ?? 'md'] }}>
182
203
  <NativeButton
183
204
  label={isStringLabel ? children : undefined}
184
205
  variant={NATIVE_VARIANT[variant ?? 'primary']}
@@ -13,6 +13,7 @@ import {
13
13
  import { Pressable, View, type ViewProps } from 'react-native';
14
14
  import Animated, { FadeIn, FadeOut, ZoomIn } from 'react-native-reanimated';
15
15
  import { Portal } from '../../primitives/portal';
16
+ import { Scrim } from '../../primitives/scrim';
16
17
  import { Text, type TextProps } from '../../primitives/text';
17
18
  import { cn } from '../../utils/cn';
18
19
 
@@ -82,12 +83,19 @@ export interface DialogContentProps extends ViewProps {
82
83
  className?: string;
83
84
  /** Tap on the backdrop closes the dialog. Default true. */
84
85
  dismissible?: boolean;
86
+ /**
87
+ * Frost the screen behind the dialog instead of dimming it. Uses `expo-blur`
88
+ * when installed and falls back to the dim when it is not, so it is safe to
89
+ * pass either way.
90
+ */
91
+ blur?: boolean;
85
92
  children?: ReactNode;
86
93
  }
87
94
 
88
95
  function DialogContent({
89
96
  className,
90
97
  dismissible = true,
98
+ blur = false,
91
99
  children,
92
100
  ...props
93
101
  }: DialogContentProps) {
@@ -106,9 +114,10 @@ function DialogContent({
106
114
  exiting={FadeOut.duration(150)}
107
115
  className="absolute inset-0 items-center justify-center p-6"
108
116
  >
117
+ <Scrim blur={blur} />
109
118
  <Pressable
110
119
  accessibilityLabel="Close dialog"
111
- className="absolute inset-0 bg-black/50"
120
+ className="absolute inset-0"
112
121
  onPress={dismissible ? () => setOpen(false) : undefined}
113
122
  />
114
123
  <Animated.View
@@ -6,26 +6,49 @@
6
6
  * is a pair of CSS pseudo-elements; React Native has none, so it is two
7
7
  * absolutely positioned siblings with static rotate transforms.
8
8
  */
9
- import { forwardRef, type ReactNode } from 'react';
9
+ import { createContext, forwardRef, useContext, type ReactNode } from 'react';
10
10
  import { View, type Text as RNText, type ViewProps } from 'react-native';
11
11
  import { tv, type VariantProps } from 'tailwind-variants';
12
12
  import { Text, type TextProps } from '../../primitives/text';
13
13
 
14
+ type EmptyStateSize = 'sm' | 'default' | 'lg';
15
+
16
+ /** Sub-components read the size so the title, description and media scale with the root. */
17
+ const EmptyStateContext = createContext<{ size: EmptyStateSize }>({ size: 'default' });
18
+
14
19
  const emptyStateVariants = tv({
15
20
  slots: {
16
- root: 'w-full flex-1 items-center justify-center gap-6 px-6 py-12',
21
+ root: 'w-full items-center justify-center',
17
22
  header: 'w-full max-w-sm items-center gap-1',
18
- title: 'text-center text-xl font-semibold text-foreground',
19
- description: 'text-center text-sm text-muted-foreground',
23
+ title: 'text-center font-semibold text-foreground',
24
+ description: 'text-center text-muted-foreground',
20
25
  content: 'w-full max-w-sm items-center gap-3',
21
26
  },
27
+ variants: {
28
+ variant: {
29
+ // Fills whatever space it is given — a whole screen, or a flex parent.
30
+ default: { root: 'flex-1' },
31
+ // A self-contained block that sits inside content rather than filling a
32
+ // screen: bordered, on the card fill, sized to its contents.
33
+ card: { root: 'rounded-2xl border border-border bg-card' },
34
+ },
35
+ size: {
36
+ sm: { root: 'gap-4 px-4 py-8', title: 'text-base', description: 'text-sm' },
37
+ default: { root: 'gap-6 px-6 py-12', title: 'text-xl', description: 'text-sm' },
38
+ lg: { root: 'gap-6 px-6 py-16', title: 'text-2xl', description: 'text-base' },
39
+ },
40
+ },
41
+ defaultVariants: {
42
+ variant: 'default',
43
+ size: 'default',
44
+ },
22
45
  });
23
46
 
24
47
  const mediaVariants = tv({
25
48
  slots: {
26
49
  wrapper: 'items-center justify-center',
27
- card: 'h-9 w-9 shrink-0 items-center justify-center',
28
- ghost: 'absolute h-9 w-9 rounded-md border border-border bg-card',
50
+ card: 'shrink-0 items-center justify-center',
51
+ ghost: 'absolute rounded-md border border-border bg-card',
29
52
  },
30
53
  variants: {
31
54
  variant: {
@@ -37,21 +60,41 @@ const mediaVariants = tv({
37
60
  card: 'rounded-md border border-border bg-card',
38
61
  },
39
62
  },
63
+ size: {
64
+ sm: { card: 'h-8 w-8', ghost: 'h-8 w-8' },
65
+ default: { card: 'h-9 w-9', ghost: 'h-9 w-9' },
66
+ lg: { card: 'h-11 w-11', ghost: 'h-11 w-11' },
67
+ },
40
68
  },
41
69
  defaultVariants: {
42
70
  variant: 'default',
71
+ size: 'default',
43
72
  },
44
73
  });
45
74
 
46
- export interface EmptyStateProps extends ViewProps {
75
+ export interface EmptyStateProps
76
+ extends ViewProps,
77
+ VariantProps<typeof emptyStateVariants> {
47
78
  className?: string;
79
+ /**
80
+ * `default` fills its parent — a whole screen, or a flex slot. `card` is a
81
+ * self-contained bordered block for an empty state that sits inside content
82
+ * rather than owning the screen.
83
+ */
84
+ variant?: 'default' | 'card';
85
+ /** Scales the padding, media, title and description together. */
86
+ size?: EmptyStateSize;
48
87
  children?: ReactNode;
49
88
  }
50
89
 
51
90
  const EmptyStateRoot = forwardRef<View, EmptyStateProps>(
52
- ({ className, ...props }, ref) => {
53
- const { root } = emptyStateVariants();
54
- return <View ref={ref} className={root({ className })} {...props} />;
91
+ ({ className, variant, size = 'default', ...props }, ref) => {
92
+ const { root } = emptyStateVariants({ variant, size });
93
+ return (
94
+ <EmptyStateContext.Provider value={{ size }}>
95
+ <View ref={ref} className={root({ className })} {...props} />
96
+ </EmptyStateContext.Provider>
97
+ );
55
98
  }
56
99
  );
57
100
  EmptyStateRoot.displayName = 'EmptyState';
@@ -78,7 +121,8 @@ export interface EmptyStateMediaProps
78
121
  */
79
122
  const EmptyStateMedia = forwardRef<View, EmptyStateMediaProps>(
80
123
  ({ className, variant = 'default', children, ...props }, ref) => {
81
- const { wrapper, card, ghost } = mediaVariants({ variant });
124
+ const { size } = useContext(EmptyStateContext);
125
+ const { wrapper, card, ghost } = mediaVariants({ variant, size });
82
126
 
83
127
  return (
84
128
  <View ref={ref} className={wrapper({ className: `mb-4 ${className ?? ''}` })} {...props}>
@@ -104,21 +148,23 @@ const EmptyStateMedia = forwardRef<View, EmptyStateMediaProps>(
104
148
  EmptyStateMedia.displayName = 'EmptyState.Media';
105
149
 
106
150
  const EmptyStateTitle = forwardRef<RNText, TextProps>(({ className, ...props }, ref) => {
107
- const { title } = emptyStateVariants();
151
+ const { size } = useContext(EmptyStateContext);
152
+ const { title } = emptyStateVariants({ size });
108
153
  return <Text ref={ref} className={title({ className })} {...props} />;
109
154
  });
110
155
  EmptyStateTitle.displayName = 'EmptyState.Title';
111
156
 
112
157
  const EmptyStateDescription = forwardRef<RNText, TextProps>(
113
158
  ({ className, ...props }, ref) => {
114
- const { description } = emptyStateVariants();
159
+ const { size } = useContext(EmptyStateContext);
160
+ const { description } = emptyStateVariants({ size });
115
161
  return <Text ref={ref} className={description({ className })} {...props} />;
116
162
  }
117
163
  );
118
164
  EmptyStateDescription.displayName = 'EmptyState.Description';
119
165
 
120
166
  /** Slot below the header, for actions such as a primary button. */
121
- const EmptyStateContent = forwardRef<View, EmptyStateProps>(
167
+ const EmptyStateContent = forwardRef<View, ViewProps & { className?: string }>(
122
168
  ({ className, ...props }, ref) => {
123
169
  const { content } = emptyStateVariants();
124
170
  return <View ref={ref} className={content({ className })} {...props} />;
@@ -15,28 +15,67 @@
15
15
  * </Frame.Header>
16
16
  * <Frame.Panel>
17
17
  * <Frame.Row>…</Frame.Row>
18
- * <Frame.Row divided>…</Frame.Row>
18
+ * <Frame.Row>…</Frame.Row>
19
19
  * </Frame.Panel>
20
20
  * <Frame.Footer>Updated 2 minutes ago</Frame.Footer>
21
21
  * </Frame>
22
22
  * ```
23
+ *
24
+ * The panel draws the hairlines between its own rows. React Native has no
25
+ * `:first-child`, so the alternative is every caller writing
26
+ * `divided={index > 0}` on every row and getting it wrong once.
23
27
  */
24
- import { forwardRef, type ReactNode } from 'react';
25
- import { View, type Text as RNText, type ViewProps } from 'react-native';
28
+ import { Children, cloneElement, forwardRef, isValidElement, type ReactNode } from 'react';
29
+ import {
30
+ Pressable,
31
+ View,
32
+ type PressableProps,
33
+ type Text as RNText,
34
+ type ViewProps,
35
+ } from 'react-native';
36
+ import { tv } from 'tailwind-variants';
37
+ import { ChevronRightIcon } from '../../icons';
26
38
  import { Text, type TextProps } from '../../primitives/text';
27
39
  import { cn } from '../../utils/cn';
28
40
 
41
+ const frameVariants = tv({
42
+ slots: {
43
+ root: '',
44
+ panel: 'overflow-hidden rounded-xl border border-border bg-card',
45
+ },
46
+ variants: {
47
+ variant: {
48
+ default: { root: 'rounded-2xl border border-border bg-surface p-1.5' },
49
+ // No shell: the panel is the whole widget. For a Frame nested inside a
50
+ // card that already draws a border, where the shell's own edge sitting
51
+ // just inside it reads as a double line.
52
+ plain: { root: '', panel: 'rounded-2xl' },
53
+ },
54
+ },
55
+ defaultVariants: {
56
+ variant: 'default',
57
+ },
58
+ });
59
+
60
+ export type FrameVariant = 'default' | 'plain';
61
+
29
62
  export interface FrameProps extends ViewProps {
30
63
  className?: string;
31
64
  }
32
65
 
33
- const FrameRoot = forwardRef<View, FrameProps>(({ className, ...props }, ref) => (
34
- <View
35
- ref={ref}
36
- className={cn('rounded-2xl border border-border bg-surface p-1.5', className)}
37
- {...props}
38
- />
39
- ));
66
+ export interface FrameRootProps extends FrameProps {
67
+ /**
68
+ * `plain` drops the outer shell so the panel is the widget — for a Frame
69
+ * inside a container that already draws its own border.
70
+ */
71
+ variant?: FrameVariant;
72
+ }
73
+
74
+ const FrameRoot = forwardRef<View, FrameRootProps>(
75
+ ({ className, variant, ...props }, ref) => (
76
+ <View ref={ref} className={frameVariants({ variant }).root({ className })} {...props} />
77
+ )
78
+ );
40
79
  FrameRoot.displayName = 'Frame';
41
80
 
42
81
  export interface FrameHeaderProps extends FrameProps {
@@ -103,41 +142,147 @@ const FrameDescription = forwardRef<RNText, TextProps>(
103
142
  );
104
143
  FrameDescription.displayName = 'Frame.Description';
105
144
 
145
+ /**
146
+ * Marks the parts that take part in the panel's own divider bookkeeping —
147
+ * a Row draws a line above itself, a Section draws one above its heading.
148
+ * Anything else the panel is given is left alone.
149
+ */
150
+ interface Dividable {
151
+ divided?: boolean;
152
+ }
153
+
154
+ /**
155
+ * Draws the hairline above every child but the first. An explicit `divided`
156
+ * on a child wins, so a row can still opt out or force one.
157
+ */
158
+ function divideChildren(children: ReactNode) {
159
+ let seen = 0;
160
+ return Children.map(children, (child) => {
161
+ if (!isValidElement<Dividable>(child)) return child;
162
+ if (!DIVIDABLE.has(child.type)) return child;
163
+
164
+ const index = seen++;
165
+ if (child.props.divided !== undefined) return child;
166
+ return cloneElement(child, { divided: index > 0 });
167
+ });
168
+ }
169
+
170
+ export interface FramePanelProps extends FrameProps {
171
+ /**
172
+ * Set false to place the hairlines by hand instead — for a panel whose rows
173
+ * are generated somewhere the divider order is not obvious.
174
+ */
175
+ dividers?: boolean;
176
+ children?: ReactNode;
177
+ }
178
+
106
179
  /**
107
180
  * The inset card holding the frame's content — the raised surface sitting in
108
181
  * from the shell's edges, with its own smaller radius.
109
182
  */
110
- const FramePanel = forwardRef<View, FrameProps>(({ className, ...props }, ref) => (
111
- <View
112
- ref={ref}
113
- className={cn('overflow-hidden rounded-xl border border-border bg-card', className)}
114
- {...props}
115
- />
116
- ));
183
+ const FramePanel = forwardRef<View, FramePanelProps>(
184
+ ({ className, dividers = true, children, ...props }, ref) => (
185
+ <View ref={ref} className={frameVariants().panel({ className })} {...props}>
186
+ {dividers ? divideChildren(children) : children}
187
+ </View>
188
+ )
189
+ );
117
190
  FramePanel.displayName = 'Frame.Panel';
118
191
 
192
+ export interface FrameRowProps extends Omit<PressableProps, 'children'>, Dividable {
193
+ className?: string;
194
+ /**
195
+ * Draw a hairline above this row. `Frame.Panel` sets it for you; pass it
196
+ * explicitly to override the panel's decision either way.
197
+ */
198
+ divided?: boolean;
199
+ /** Trailing chevron marking the row as leading somewhere. */
200
+ chevron?: boolean;
201
+ children?: ReactNode;
202
+ }
203
+
119
204
  /**
120
- * A row inside a Frame.Panel. Pass `divided` on every row after the first to
121
- * draw a hairline separator above it (React Native has no :first-child).
205
+ * A row inside a Frame.Panel. Give it an `onPress` and it becomes a real
206
+ * pressable — press feedback, a button role — rather than a View with a
207
+ * handler bolted on.
122
208
  */
123
- export interface FrameRowProps extends FrameProps {
209
+ const FrameRow = forwardRef<View, FrameRowProps>(
210
+ ({ className, divided, chevron, children, onPress, ...props }, ref) => {
211
+ const classes = cn(
212
+ 'flex-row items-center gap-3 px-4 py-3.5',
213
+ divided && 'border-t border-border',
214
+ onPress && 'active:bg-muted',
215
+ className
216
+ );
217
+
218
+ const body = (
219
+ <>
220
+ {children}
221
+ {chevron ? <ChevronRightIcon size={16} /> : null}
222
+ </>
223
+ );
224
+
225
+ if (!onPress) {
226
+ return (
227
+ <View ref={ref} className={classes} {...(props as ViewProps)}>
228
+ {body}
229
+ </View>
230
+ );
231
+ }
232
+
233
+ return (
234
+ <Pressable
235
+ ref={ref}
236
+ accessibilityRole="button"
237
+ onPress={onPress}
238
+ className={classes}
239
+ {...props}
240
+ >
241
+ {body}
242
+ </Pressable>
243
+ );
244
+ }
245
+ );
246
+ FrameRow.displayName = 'Frame.Row';
247
+
248
+ export interface FrameSectionProps extends FrameProps, Dividable {
249
+ /** Heading above the rows. Strings are wrapped for you. */
250
+ title?: ReactNode;
124
251
  divided?: boolean;
252
+ children?: ReactNode;
125
253
  }
126
254
 
127
- const FrameRow = forwardRef<View, FrameRowProps>(
128
- ({ className, divided, ...props }, ref) => (
255
+ /**
256
+ * A labelled cluster of rows inside a Panel, for a widget holding more than
257
+ * one group. It divides its own rows the way the panel does, so the two nest
258
+ * without either having to know about the other.
259
+ */
260
+ const FrameSection = forwardRef<View, FrameSectionProps>(
261
+ ({ className, title, divided, children, ...props }, ref) => (
129
262
  <View
130
263
  ref={ref}
131
- className={cn(
132
- 'flex-row items-center gap-3 px-4 py-3.5',
133
- divided && 'border-t border-border',
134
- className
135
- )}
264
+ className={cn(divided && 'border-t border-border', className)}
136
265
  {...props}
137
- />
266
+ >
267
+ {title ? (
268
+ <View className="bg-muted/40 px-4 pb-1.5 pt-2.5">
269
+ {typeof title === 'string' ? (
270
+ <Text size="xs" weight="medium" muted className="uppercase tracking-wider">
271
+ {title}
272
+ </Text>
273
+ ) : (
274
+ title
275
+ )}
276
+ </View>
277
+ ) : null}
278
+ {divideChildren(children)}
279
+ </View>
138
280
  )
139
281
  );
140
- FrameRow.displayName = 'Frame.Row';
282
+ FrameSection.displayName = 'Frame.Section';
283
+
284
+ /** Parts the panel divides. Declared after them, since it holds references. */
285
+ const DIVIDABLE = new Set<unknown>([FrameRow, FrameSection]);
141
286
 
142
287
  export interface FrameFooterProps extends FrameProps {
143
288
  children?: ReactNode;
@@ -169,6 +314,7 @@ export const Frame = Object.assign(FrameRoot, {
169
314
  Action: FrameAction,
170
315
  Description: FrameDescription,
171
316
  Panel: FramePanel,
317
+ Section: FrameSection,
172
318
  Row: FrameRow,
173
319
  Footer: FrameFooter,
174
320
  });