panelui-native 0.81.0 → 0.83.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 (73) hide show
  1. package/lib/module/components/bottom-sheet/index.js +189 -21
  2. package/lib/module/components/bottom-sheet/index.js.map +1 -1
  3. package/lib/module/components/button/index.js +13 -0
  4. package/lib/module/components/button/index.js.map +1 -1
  5. package/lib/module/components/button-group/index.js +3 -0
  6. package/lib/module/components/button-group/index.js.map +1 -1
  7. package/lib/module/components/image-generation/dot-field.js +267 -0
  8. package/lib/module/components/image-generation/dot-field.js.map +1 -0
  9. package/lib/module/components/image-generation/index.js +459 -0
  10. package/lib/module/components/image-generation/index.js.map +1 -0
  11. package/lib/module/components/kpi/index.js +2 -4
  12. package/lib/module/components/kpi/index.js.map +1 -1
  13. package/lib/module/components/menu/index.js +8 -16
  14. package/lib/module/components/menu/index.js.map +1 -1
  15. package/lib/module/components/panelside/index.js +340 -33
  16. package/lib/module/components/panelside/index.js.map +1 -1
  17. package/lib/module/components/select/index.js +127 -23
  18. package/lib/module/components/select/index.js.map +1 -1
  19. package/lib/module/components/slide-button/index.js +610 -0
  20. package/lib/module/components/slide-button/index.js.map +1 -0
  21. package/lib/module/components/slide-button/slide-button-track.js +98 -0
  22. package/lib/module/components/slide-button/slide-button-track.js.map +1 -0
  23. package/lib/module/icons/index.js +553 -1934
  24. package/lib/module/icons/index.js.map +1 -1
  25. package/lib/module/index.js +3 -1
  26. package/lib/module/index.js.map +1 -1
  27. package/lib/module/native/index.js +24 -0
  28. package/lib/module/native/index.js.map +1 -1
  29. package/lib/module/primitives/portal.js +15 -1
  30. package/lib/module/primitives/portal.js.map +1 -1
  31. package/lib/typescript/src/components/bottom-sheet/index.d.ts +17 -1
  32. package/lib/typescript/src/components/bottom-sheet/index.d.ts.map +1 -1
  33. package/lib/typescript/src/components/button/index.d.ts +12 -0
  34. package/lib/typescript/src/components/button/index.d.ts.map +1 -1
  35. package/lib/typescript/src/components/button-group/index.d.ts +9 -0
  36. package/lib/typescript/src/components/button-group/index.d.ts.map +1 -1
  37. package/lib/typescript/src/components/image-generation/dot-field.d.ts +96 -0
  38. package/lib/typescript/src/components/image-generation/dot-field.d.ts.map +1 -0
  39. package/lib/typescript/src/components/image-generation/index.d.ts +134 -0
  40. package/lib/typescript/src/components/image-generation/index.d.ts.map +1 -0
  41. package/lib/typescript/src/components/kpi/index.d.ts.map +1 -1
  42. package/lib/typescript/src/components/menu/index.d.ts.map +1 -1
  43. package/lib/typescript/src/components/panelside/index.d.ts +194 -10
  44. package/lib/typescript/src/components/panelside/index.d.ts.map +1 -1
  45. package/lib/typescript/src/components/select/index.d.ts +75 -2
  46. package/lib/typescript/src/components/select/index.d.ts.map +1 -1
  47. package/lib/typescript/src/components/slide-button/index.d.ts +317 -0
  48. package/lib/typescript/src/components/slide-button/index.d.ts.map +1 -0
  49. package/lib/typescript/src/components/slide-button/slide-button-track.d.ts +70 -0
  50. package/lib/typescript/src/components/slide-button/slide-button-track.d.ts.map +1 -0
  51. package/lib/typescript/src/icons/index.d.ts +136 -189
  52. package/lib/typescript/src/icons/index.d.ts.map +1 -1
  53. package/lib/typescript/src/index.d.ts +4 -2
  54. package/lib/typescript/src/index.d.ts.map +1 -1
  55. package/lib/typescript/src/native/index.d.ts +26 -0
  56. package/lib/typescript/src/native/index.d.ts.map +1 -1
  57. package/lib/typescript/src/primitives/portal.d.ts.map +1 -1
  58. package/package.json +3 -2
  59. package/src/components/bottom-sheet/index.tsx +223 -20
  60. package/src/components/button/index.tsx +10 -2
  61. package/src/components/button-group/index.tsx +1 -0
  62. package/src/components/image-generation/dot-field.ts +282 -0
  63. package/src/components/image-generation/index.tsx +537 -0
  64. package/src/components/kpi/index.tsx +2 -4
  65. package/src/components/menu/index.tsx +10 -15
  66. package/src/components/panelside/index.tsx +498 -33
  67. package/src/components/select/index.tsx +158 -31
  68. package/src/components/slide-button/index.tsx +681 -0
  69. package/src/components/slide-button/slide-button-track.ts +100 -0
  70. package/src/icons/index.tsx +338 -1327
  71. package/src/index.ts +28 -0
  72. package/src/native/index.ts +45 -0
  73. package/src/primitives/portal.tsx +15 -1
@@ -89,7 +89,33 @@ interface SwiftUIModifiers {
89
89
  buttonStyle: (style: 'automatic' | 'bordered' | 'borderedProminent' | 'borderless' | 'glass' | 'glassProminent' | 'plain') => unknown;
90
90
  buttonBorderShape: (shape: 'automatic' | 'capsule' | 'roundedRectangle' | 'circle', cornerRadius?: number) => unknown;
91
91
  controlSize: (size: 'mini' | 'small' | 'regular' | 'large' | 'extraLarge') => unknown;
92
+ /**
93
+ * Paints a sheet's own surface, rather than a background behind its content.
94
+ *
95
+ * It is the only way out of the material a sheet is drawn in by default, and
96
+ * it reaches what an ordinary background cannot: the grabber's strip at the
97
+ * top and the safe-area inset at the bottom, which belong to the sheet's
98
+ * chrome and not to anything hosted inside it.
99
+ *
100
+ * **iOS 16.4 and up.** Below that it is inert and the sheet keeps its
101
+ * material — the same shape as glass being inert below iOS 26, and the same
102
+ * trap: indistinguishable from the prop not working. Check the OS before
103
+ * changing any code on a report of "the colour did nothing".
104
+ */
105
+ presentationBackground: (color: string) => unknown;
92
106
  }
93
107
  export declare function getSwiftUIModifiers(): SwiftUIModifiers | null;
108
+ /**
109
+ * The Compose view modifiers — the other half of the same door.
110
+ *
111
+ * Android's toolkit has its own modifier system and its own vocabulary, so a
112
+ * modifier built for one platform is not a modifier the other can read. Where
113
+ * both are asked the same question the answers are written separately, here and
114
+ * above, rather than one being sent to both.
115
+ */
116
+ interface ComposeModifiers {
117
+ background: (color: string) => unknown;
118
+ }
119
+ export declare function getComposeModifiers(): ComposeModifiers | null;
94
120
  export {};
95
121
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../src/native/index.ts"],"names":[],"mappings":"AAsBA,OAAO,KAAK,EAAE,aAAa,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;AAEtD,UAAU,cAAc;IACtB,IAAI,EAAE,aAAa,CAAC;QAClB,QAAQ,CAAC,EAAE,SAAS,CAAC;QACrB;;;;;;;;;;;;WAYG;QACH,aAAa,CAAC,EAAE,OAAO,GAAG;YAAE,QAAQ,CAAC,EAAE,OAAO,CAAC;YAAC,UAAU,CAAC,EAAE,OAAO,CAAA;SAAE,CAAC;QACvE;;;;;;;;;;;;;;;WAeG;QACH,cAAc,CAAC,EAAE,KAAK,GAAG,WAAW,GAAG,UAAU,CAAC;QAClD,KAAK,CAAC,EAAE,OAAO,CAAC;QAChB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;KACxB,CAAC,CAAC;IACH;;;;OAIG;IACH,UAAU,EAAE,aAAa,CAAC;QACxB,QAAQ,CAAC,EAAE,SAAS,CAAC;QACrB,aAAa,CAAC,EAAE,OAAO,CAAC;QACxB,KAAK,CAAC,EAAE,OAAO,CAAC;KACjB,CAAC,CAAC;IACH;;;;;OAKG;IACH,MAAM,EAAE,aAAa,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;IAC/C,MAAM,EAAE,aAAa,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;IAC/C,MAAM,EAAE,aAAa,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;IAC/C,MAAM,EAAE,aAAa,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,GAAG;QAC/C,IAAI,EAAE,aAAa,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;KAC9C,CAAC;IACF,WAAW,EAAE,aAAa,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;CACrD;AAKD;;;;GAIG;AACH,wBAAgB,WAAW,IAAI,cAAc,GAAG,IAAI,CAgBnD;AAED,gEAAgE;AAChE,wBAAgB,WAAW,IAAI,OAAO,CAErC;AAED;;;;;;;;;;;;;GAaG;AACH,UAAU,gBAAgB;IACxB,WAAW,EAAE,CACX,KAAK,EACD,WAAW,GACX,UAAU,GACV,mBAAmB,GACnB,YAAY,GACZ,OAAO,GACP,gBAAgB,GAChB,OAAO,KACR,OAAO,CAAC;IACb,iBAAiB,EAAE,CACjB,KAAK,EAAE,WAAW,GAAG,SAAS,GAAG,kBAAkB,GAAG,QAAQ,EAC9D,YAAY,CAAC,EAAE,MAAM,KAClB,OAAO,CAAC;IACb,WAAW,EAAE,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,GAAG,SAAS,GAAG,OAAO,GAAG,YAAY,KAAK,OAAO,CAAC;CACvF;AAKD,wBAAgB,mBAAmB,IAAI,gBAAgB,GAAG,IAAI,CAc7D"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../src/native/index.ts"],"names":[],"mappings":"AAsBA,OAAO,KAAK,EAAE,aAAa,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;AAEtD,UAAU,cAAc;IACtB,IAAI,EAAE,aAAa,CAAC;QAClB,QAAQ,CAAC,EAAE,SAAS,CAAC;QACrB;;;;;;;;;;;;WAYG;QACH,aAAa,CAAC,EAAE,OAAO,GAAG;YAAE,QAAQ,CAAC,EAAE,OAAO,CAAC;YAAC,UAAU,CAAC,EAAE,OAAO,CAAA;SAAE,CAAC;QACvE;;;;;;;;;;;;;;;WAeG;QACH,cAAc,CAAC,EAAE,KAAK,GAAG,WAAW,GAAG,UAAU,CAAC;QAClD,KAAK,CAAC,EAAE,OAAO,CAAC;QAChB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;KACxB,CAAC,CAAC;IACH;;;;OAIG;IACH,UAAU,EAAE,aAAa,CAAC;QACxB,QAAQ,CAAC,EAAE,SAAS,CAAC;QACrB,aAAa,CAAC,EAAE,OAAO,CAAC;QACxB,KAAK,CAAC,EAAE,OAAO,CAAC;KACjB,CAAC,CAAC;IACH;;;;;OAKG;IACH,MAAM,EAAE,aAAa,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;IAC/C,MAAM,EAAE,aAAa,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;IAC/C,MAAM,EAAE,aAAa,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;IAC/C,MAAM,EAAE,aAAa,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,GAAG;QAC/C,IAAI,EAAE,aAAa,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;KAC9C,CAAC;IACF,WAAW,EAAE,aAAa,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;CACrD;AAKD;;;;GAIG;AACH,wBAAgB,WAAW,IAAI,cAAc,GAAG,IAAI,CAgBnD;AAED,gEAAgE;AAChE,wBAAgB,WAAW,IAAI,OAAO,CAErC;AAED;;;;;;;;;;;;;GAaG;AACH,UAAU,gBAAgB;IACxB,WAAW,EAAE,CACX,KAAK,EACD,WAAW,GACX,UAAU,GACV,mBAAmB,GACnB,YAAY,GACZ,OAAO,GACP,gBAAgB,GAChB,OAAO,KACR,OAAO,CAAC;IACb,iBAAiB,EAAE,CACjB,KAAK,EAAE,WAAW,GAAG,SAAS,GAAG,kBAAkB,GAAG,QAAQ,EAC9D,YAAY,CAAC,EAAE,MAAM,KAClB,OAAO,CAAC;IACb,WAAW,EAAE,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,GAAG,SAAS,GAAG,OAAO,GAAG,YAAY,KAAK,OAAO,CAAC;IACtF;;;;;;;;;;;;OAYG;IACH,sBAAsB,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,OAAO,CAAC;CACpD;AAKD,wBAAgB,mBAAmB,IAAI,gBAAgB,GAAG,IAAI,CAc7D;AAED;;;;;;;GAOG;AACH,UAAU,gBAAgB;IACxB,UAAU,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,OAAO,CAAC;CACxC;AAKD,wBAAgB,mBAAmB,IAAI,gBAAgB,GAAG,IAAI,CAc7D"}
@@ -1 +1 @@
1
- {"version":3,"file":"portal.d.ts","sourceRoot":"","sources":["../../../../src/primitives/portal.tsx"],"names":[],"mappings":"AAAA,OAAO,EAOL,KAAK,SAAS,EACf,MAAM,OAAO,CAAC;AAsCf,wBAAgB,cAAc,CAAC,EAAE,QAAQ,EAAE,EAAE;IAAE,QAAQ,EAAE,SAAS,CAAA;CAAE,+BAgBnE;AAUD,4EAA4E;AAC5E,wBAAgB,MAAM,CAAC,EAAE,QAAQ,EAAE,EAAE;IAAE,QAAQ,EAAE,SAAS,CAAA;CAAE,QAU3D;AAED,wDAAwD;AACxD,wBAAgB,WAAW,CAAC,EAAE,QAAQ,EAAE,EAAE;IAAE,QAAQ,EAAE,SAAS,CAAA;CAAE,+BAehE;AAED,2EAA2E;AAC3E,wBAAgB,kBAAkB,CAAC,EAAE,QAAQ,EAAE,EAAE;IAAE,QAAQ,EAAE,SAAS,CAAA;CAAE,+BAOvE;AAED,qDAAqD;AACrD,wBAAgB,uBAAuB,IAAI,OAAO,CAGjD;AAkBD,mFAAmF;AACnF,wBAAgB,UAAU,gCAWzB"}
1
+ {"version":3,"file":"portal.d.ts","sourceRoot":"","sources":["../../../../src/primitives/portal.tsx"],"names":[],"mappings":"AAAA,OAAO,EAOL,KAAK,SAAS,EACf,MAAM,OAAO,CAAC;AAsCf,wBAAgB,cAAc,CAAC,EAAE,QAAQ,EAAE,EAAE;IAAE,QAAQ,EAAE,SAAS,CAAA;CAAE,+BAgBnE;AAUD,4EAA4E;AAC5E,wBAAgB,MAAM,CAAC,EAAE,QAAQ,EAAE,EAAE;IAAE,QAAQ,EAAE,SAAS,CAAA;CAAE,QAwB3D;AAED,wDAAwD;AACxD,wBAAgB,WAAW,CAAC,EAAE,QAAQ,EAAE,EAAE;IAAE,QAAQ,EAAE,SAAS,CAAA;CAAE,+BAehE;AAED,2EAA2E;AAC3E,wBAAgB,kBAAkB,CAAC,EAAE,QAAQ,EAAE,EAAE;IAAE,QAAQ,EAAE,SAAS,CAAA;CAAE,+BAOvE;AAED,qDAAqD;AACrD,wBAAgB,uBAAuB,IAAI,OAAO,CAGjD;AAkBD,mFAAmF;AACnF,wBAAgB,UAAU,gCAWzB"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "panelui-native",
3
- "version": "0.81.0",
3
+ "version": "0.83.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",
@@ -149,8 +149,9 @@
149
149
  "uniwind": ">=1.0.0"
150
150
  },
151
151
  "dependencies": {
152
+ "@hugeicons/core-free-icons": "^4.3.0",
153
+ "@hugeicons/react-native": "^1.0.16",
152
154
  "clsx": "^2.1.1",
153
- "lucide-react-native": "^1.28.0",
154
155
  "tailwind-merge": "^3.3.1",
155
156
  "tailwind-variants": "^3.2.2"
156
157
  },
@@ -6,6 +6,7 @@ import {
6
6
  useContext,
7
7
  useEffect,
8
8
  useMemo,
9
+ useRef,
9
10
  useState,
10
11
  type ComponentProps,
11
12
  type ReactElement,
@@ -23,10 +24,9 @@ import { Gesture, GestureDetector } from 'react-native-gesture-handler';
23
24
  import Animated, {
24
25
  Extrapolation,
25
26
  FadeIn,
27
+ cancelAnimation,
26
28
  interpolate,
27
- FadeOut,
28
29
  SlideInDown,
29
- SlideOutDown,
30
30
  runOnJS,
31
31
  useAnimatedScrollHandler,
32
32
  useAnimatedStyle,
@@ -38,7 +38,7 @@ import Animated, {
38
38
  } from 'react-native-reanimated';
39
39
  import { useSafeAreaInsets } from 'react-native-safe-area-context';
40
40
  import { tv } from 'tailwind-variants';
41
- import { getNativeUI } from '../../native';
41
+ import { getComposeModifiers, getNativeUI, getSwiftUIModifiers } from '../../native';
42
42
  import { XIcon } from '../../icons';
43
43
  import { ModalPortal } from '../../primitives/portal';
44
44
  import { Scrim } from '../../primitives/scrim';
@@ -80,10 +80,6 @@ const DISMISS_VELOCITY = 800;
80
80
  * is a new object every commit, and the sheet re-renders while it is open.
81
81
  */
82
82
  const ENTERING = SlideInDown.springify().dampingRatio(0.9).duration(420);
83
- // Leaving is quicker than arriving, but not by much: the sheet is still
84
- // crossing the whole screen, and a surface that big snapping out of existence
85
- // is more startling than the delay it saves is worth.
86
- const EXITING = SlideOutDown.springify().dampingRatio(1).duration(340);
87
83
 
88
84
  /**
89
85
  * Where a flick would come to rest if it kept decelerating — Apple's
@@ -153,6 +149,22 @@ export interface BottomSheetProps {
153
149
  * nearest of `half` / `full`.
154
150
  */
155
151
  snapPoints?: ('half' | 'full' | { fraction: number } | { height: number })[];
152
+ /**
153
+ * Paint the native sheet a solid colour instead of the material the platform
154
+ * draws it in by default.
155
+ *
156
+ * The platform's sheet is translucent — on iOS 26 that is Liquid Glass — and
157
+ * what is behind it shows through. That is right for a sheet laid over
158
+ * content worth glimpsing and wrong for one that is a surface of the app's
159
+ * own, where the app's ground shifting under it reads as a mistake.
160
+ *
161
+ * `true` uses the theme's popover surface, so the sheet matches the rest of
162
+ * the app in both schemes. A string paints that colour exactly.
163
+ *
164
+ * It only reaches the platform's sheet, so it does nothing without `native`.
165
+ * On iOS below 16.4 the sheet keeps its material.
166
+ */
167
+ nativeBackground?: boolean | string;
156
168
  }
157
169
 
158
170
  /**
@@ -162,6 +174,22 @@ export interface BottomSheetProps {
162
174
  */
163
175
  const DETENT_FRACTION = { half: 0.5, full: 0.9 } as const;
164
176
 
177
+ /**
178
+ * How long to treat a dismissal *we* asked for as still in flight.
179
+ *
180
+ * A timer, because there is nothing to wait on. The platform reports a
181
+ * dismissal the reader performed — that report is the binding writing its new
182
+ * value back — but not one we asked for, where the binding already holds the
183
+ * value it would have written and the change is suppressed at the source. So
184
+ * the only sheet whose departure can be observed is the one we did not ask to
185
+ * leave.
186
+ *
187
+ * Comfortably past the system's own sheet transition, since the cost of being
188
+ * late is a present held a little longer than it needed to be, and the cost of
189
+ * being early is the platform dropping it and no sheet at all.
190
+ */
191
+ const NATIVE_DISMISS_MS = 400;
192
+
165
193
  /**
166
194
  * The height the content should at least fill for a given set of detents.
167
195
  *
@@ -195,6 +223,29 @@ export function bottomSheetDetentHeight(
195
223
  return undefined;
196
224
  }
197
225
 
226
+ /**
227
+ * The modifier that paints a native sheet's own surface, for whichever toolkit
228
+ * is drawing it — or nothing, where neither is reachable.
229
+ *
230
+ * A sheet's surface is not the background of anything hosted in it. The
231
+ * grabber's strip at the top and the safe-area inset at the bottom are the
232
+ * sheet's own chrome, and a colour put behind the content stops short of both
233
+ * of them — so the sheet arrives two-tone, and shifts as it moves between
234
+ * detents. These two reach the surface itself.
235
+ *
236
+ * The toolkits do not share a vocabulary, so the question is asked of each in
237
+ * its own terms rather than one answer being sent to both.
238
+ */
239
+ function nativeSheetSurface(color: string): unknown[] | undefined {
240
+ const swiftUI = getSwiftUIModifiers();
241
+ if (swiftUI) return [swiftUI.presentationBackground(color)];
242
+
243
+ const compose = getComposeModifiers();
244
+ if (compose) return [compose.background(color)];
245
+
246
+ return undefined;
247
+ }
248
+
198
249
  /**
199
250
  * Set by the root so Content knows the platform is drawing the sheet, and with
200
251
  * which detents. Null means the styled sheet renders.
@@ -202,6 +253,7 @@ export function bottomSheetDetentHeight(
202
253
  const NativeSheetContext = createContext<{
203
254
  nativeUI: NonNullable<ReturnType<typeof getNativeUI>>;
204
255
  snapPoints: BottomSheetProps['snapPoints'];
256
+ background: BottomSheetProps['nativeBackground'];
205
257
  } | null>(null);
206
258
 
207
259
  /**
@@ -229,6 +281,7 @@ function BottomSheetRoot({
229
281
  defaultOpen = false,
230
282
  native,
231
283
  snapPoints,
284
+ nativeBackground,
232
285
  }: BottomSheetProps) {
233
286
  const [internalOpen, setInternalOpen] = useState(defaultOpen);
234
287
  const isControlled = open !== undefined;
@@ -249,8 +302,8 @@ function BottomSheetRoot({
249
302
 
250
303
  const nativeUI = native ? getNativeUI() : null;
251
304
  const nativeSheet = useMemo(
252
- () => (nativeUI ? { nativeUI, snapPoints } : null),
253
- [nativeUI, snapPoints]
305
+ () => (nativeUI ? { nativeUI, snapPoints, background: nativeBackground } : null),
306
+ [nativeBackground, nativeUI, snapPoints]
254
307
  );
255
308
 
256
309
  return (
@@ -356,6 +409,10 @@ function BottomSheetContent({
356
409
  const insets = useSafeAreaInsets();
357
410
  const translateY = useSharedValue(0);
358
411
  const closeTint = useCSSVariable('--color-muted-foreground');
412
+ // Read unconditionally, used only by the native branch: the platform draws
413
+ // that sheet's container, so a token can only reach it as a colour handed
414
+ // over, never as a class.
415
+ const popoverSurface = useCSSVariable('--color-popover');
359
416
 
360
417
  /*
361
418
  * The scrolling body's gesture, if there is one. It is built here rather
@@ -375,15 +432,143 @@ function BottomSheetContent({
375
432
  useBackHandler(open && dismissible, close);
376
433
 
377
434
  /*
378
- * The offset survives a close — the early return below sits after every
379
- * hook, so this component keeps its state while the sheet is hidden. A
380
- * swipe-dismiss leaves it parked a screen height down, and without this the
381
- * next open would draw the sheet below the fold behind a full backdrop:
382
- * a dimmed screen with nothing on it and no reachable close button.
435
+ * The sheet outlives `open`, and this is what keeps it alive.
436
+ *
437
+ * It used to be removed from the tree in the same commit `open` flipped,
438
+ * while an `exiting` layout animation was still declared on it. Reanimated
439
+ * then held the detached views until that spring settled — and a `duration`
440
+ * on a `springify()` animation is a target rather than a stop, so how long
441
+ * that took was not something this file decided. Reopening inside that window
442
+ * put a second sheet on screen with nothing coordinating the two.
443
+ *
444
+ * Now one animation owns the whole travel. Closing springs the sheet down and
445
+ * clears `presented` from the spring's own completion callback, so the tree
446
+ * comes down *after* the movement rather than during it. The backdrop is
447
+ * derived from the same value, so it goes with it.
448
+ *
449
+ * Reopening mid-exit is the case this really fixes: `presented` is still
450
+ * true, so nothing remounts and nothing new is scheduled — the spring is
451
+ * cancelled and the same sheet is caught on its way down.
452
+ */
453
+ const [presented, setPresented] = useState(open);
454
+
455
+ useEffect(() => {
456
+ if (open) {
457
+ cancelAnimation(translateY);
458
+ if (!presented) {
459
+ // A fresh mount starts at rest and ENTERING does the travel. Catching
460
+ // one that is still on screen must not reset it under the finger, so
461
+ // this only runs when the sheet had actually gone.
462
+ translateY.value = 0;
463
+ setPresented(true);
464
+ return;
465
+ }
466
+ translateY.value = reducedMotion
467
+ ? withTiming(0, { duration: 150 })
468
+ : withSpring(0, SPRING);
469
+ return;
470
+ }
471
+
472
+ if (!presented) return;
473
+
474
+ const land = (finished?: boolean) => {
475
+ 'worklet';
476
+ if (finished) runOnJS(setPresented)(false);
477
+ };
478
+ translateY.value = reducedMotion
479
+ ? withTiming(screenHeight, { duration: 150 }, land)
480
+ : withSpring(screenHeight, EXIT_SPRING, land);
481
+ }, [open, presented, reducedMotion, screenHeight, translateY]);
482
+
483
+ /*
484
+ * The native sheet's own presentation queue, which the platform does not have.
485
+ *
486
+ * UIKit refuses to present a sheet while the previous one is still being
487
+ * dismissed, and the request is dropped rather than queued — so a close
488
+ * followed immediately by an open leaves nothing on screen, and the reader
489
+ * sees a sheet that "took a moment" or never came at all.
490
+ *
491
+ * `isPresented` is therefore driven from here rather than straight from
492
+ * `open`: a present that arrives during a dismissal is held, and let through
493
+ * once that dismissal is over.
494
+ *
495
+ * Knowing when it is over is the hard half, because it depends on who asked.
496
+ * A dismissal the reader performed is reported — that report is the platform
497
+ * writing the new value back to us. One we asked for is not: the value is
498
+ * already what the platform would have written, so the change is suppressed
499
+ * at the source and no report is ever sent. A queue that waits for one
500
+ * regardless waits for ever, and every present after the first is held —
501
+ * which is a sheet that opens once and then never again, whether the flag was
502
+ * raised by the reader's swipe or by a Close button inside the sheet.
503
+ *
504
+ * So the two are ended by their own means: the reader's by the report, ours
505
+ * by {@link NATIVE_DISMISS_MS} — and by the report as well, if one turns up,
506
+ * since arriving early is only ever an improvement.
383
507
  */
508
+ const [nativePresented, setNativePresented] = useState(open);
509
+ const nativeOnScreen = useRef(open);
510
+ const nativeDismissing = useRef(false);
511
+ const nativeDismissTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
512
+ const openRef = useRef(open);
513
+ openRef.current = open;
514
+
515
+ /** The dismissal is over: let a held present through, if one is waiting. */
516
+ const endNativeDismissal = useCallback(() => {
517
+ if (nativeDismissTimer.current !== null) {
518
+ clearTimeout(nativeDismissTimer.current);
519
+ nativeDismissTimer.current = null;
520
+ }
521
+ if (!nativeDismissing.current) return;
522
+ nativeDismissing.current = false;
523
+
524
+ if (openRef.current) {
525
+ nativeOnScreen.current = true;
526
+ setNativePresented(true);
527
+ }
528
+ }, []);
529
+
384
530
  useEffect(() => {
385
- if (open) translateY.value = 0;
386
- }, [open, translateY]);
531
+ if (!nativeSheet) return;
532
+
533
+ if (open) {
534
+ if (nativeDismissing.current) return;
535
+ nativeOnScreen.current = true;
536
+ setNativePresented(true);
537
+ return;
538
+ }
539
+
540
+ // Only a sheet the platform still has on screen can be in the middle of
541
+ // leaving. One the reader already swiped away has reported itself gone.
542
+ if (nativeOnScreen.current) {
543
+ nativeOnScreen.current = false;
544
+ nativeDismissing.current = true;
545
+ if (nativeDismissTimer.current !== null) clearTimeout(nativeDismissTimer.current);
546
+ nativeDismissTimer.current = setTimeout(endNativeDismissal, NATIVE_DISMISS_MS);
547
+ }
548
+ setNativePresented(false);
549
+ }, [endNativeDismissal, nativeSheet, open]);
550
+
551
+ useEffect(
552
+ () => () => {
553
+ if (nativeDismissTimer.current !== null) clearTimeout(nativeDismissTimer.current);
554
+ },
555
+ []
556
+ );
557
+
558
+ const onNativeDismiss = useCallback(() => {
559
+ // The platform reporting that its sheet has gone. Whatever asked for it,
560
+ // there is nothing on screen now.
561
+ nativeOnScreen.current = false;
562
+
563
+ // Ours, or the reader's? A dismissal we asked for hands the queue back
564
+ // ahead of its window; one the reader performed is a close to report.
565
+ if (nativeDismissing.current) {
566
+ endNativeDismissal();
567
+ return;
568
+ }
569
+
570
+ if (dismissible) close();
571
+ }, [close, dismissible, endNativeDismissal]);
387
572
 
388
573
  const pan = useMemo(
389
574
  () =>
@@ -492,6 +677,22 @@ function BottomSheetContent({
492
677
  */
493
678
  const snapPoints =
494
679
  nativeSheet.snapPoints ?? (size === 'auto' ? undefined : [size]);
680
+ /*
681
+ * `true` means the theme's surface, and a string means itself. A token
682
+ * that did not resolve is dropped rather than passed on — an unresolved
683
+ * colour reaching the platform is a sheet painted some default, which is
684
+ * further from the material than leaving the material alone.
685
+ */
686
+ const requested = nativeSheet.background;
687
+ const surface =
688
+ requested === true
689
+ ? typeof popoverSurface === 'string'
690
+ ? popoverSurface
691
+ : undefined
692
+ : typeof requested === 'string'
693
+ ? requested
694
+ : undefined;
695
+ const modifiers = surface ? nativeSheetSurface(surface) : undefined;
495
696
  // The platform owns presentation, so this stays mounted and toggles
496
697
  // isPresented rather than unmounting on close.
497
698
  //
@@ -501,9 +702,10 @@ function BottomSheetContent({
501
702
  return (
502
703
  <Host matchContents ignoreSafeArea="keyboard" style={{ position: 'absolute' }}>
503
704
  <NativeBottomSheet
504
- isPresented={open}
505
- onDismiss={dismissible ? close : () => {}}
705
+ isPresented={nativePresented}
706
+ onDismiss={onNativeDismiss}
506
707
  snapPoints={snapPoints}
708
+ modifiers={modifiers}
507
709
  >
508
710
  <RNHostView matchContents>
509
711
  <BottomSheetContext.Provider value={context}>
@@ -536,7 +738,9 @@ function BottomSheetContent({
536
738
  );
537
739
  }
538
740
 
539
- if (!open) return null;
741
+ // `presented`, not `open`: the sheet stays in the tree until its own exit
742
+ // animation has finished putting it away.
743
+ if (!presented) return null;
540
744
 
541
745
  return (
542
746
  <ModalPortal>
@@ -571,7 +775,6 @@ function BottomSheetContent({
571
775
  * setting is about.
572
776
  */
573
777
  entering={reducedMotion ? FadeIn.duration(150) : ENTERING}
574
- exiting={reducedMotion ? FadeOut.duration(150) : EXITING}
575
778
  accessibilityViewIsModal
576
779
  className={sheetVariants({ detached, className })}
577
780
  {...props}
@@ -63,6 +63,12 @@ const buttonVariants = tv({
63
63
  sm: { root: 'min-h-9 min-w-9 gap-1.5 px-2.5 py-2', label: 'text-[14px]' },
64
64
  md: { root: 'min-h-11 min-w-11 px-4 py-2.5', label: 'text-[16px]' },
65
65
  lg: { root: 'min-h-12 px-6 py-2.5', label: 'text-[18px]' },
66
+ // A step above `lg`, for the one control a screen is built around — a
67
+ // compose button in a navigation panel, a primary action alone at the
68
+ // bottom of a sheet. It maps onto the platform's own extra-large control
69
+ // size under `native`, which is the size it exists to reach: below it,
70
+ // `large` is the biggest a platform button can be asked to be.
71
+ xl: { root: 'min-h-14 px-8 py-3', label: 'text-[18px]' },
66
72
  // Icon-only controls have no scalable label, so their square is stable.
67
73
  icon: { root: 'h-11 w-11 px-0' },
68
74
  },
@@ -140,10 +146,10 @@ export function useButtonGroup(): ButtonGroupContextValue | null {
140
146
  */
141
147
  const ATTACHED = 'rounded-none border-transparent shadow-none active:bg-accent';
142
148
 
143
- const SPINNER_SIZE = { sm: 'sm', md: 'sm', lg: 'md', icon: 'sm' } as const;
149
+ const SPINNER_SIZE = { sm: 'sm', md: 'sm', lg: 'md', xl: 'md', icon: 'sm' } as const;
144
150
 
145
151
  /** Extra room around the styled sizes so even the compact button reaches 48dp. */
146
- const BUTTON_HIT_SLOP = { sm: 6, md: 2, lg: 0, icon: 2 } as const;
152
+ const BUTTON_HIT_SLOP = { sm: 6, md: 2, lg: 0, xl: 0, icon: 2 } as const;
147
153
 
148
154
  /**
149
155
  * The theme token each variant's content reads against. Icons in the content
@@ -239,6 +245,7 @@ const NATIVE_HEIGHT: Record<NonNullable<ButtonVariantProps['size']>, number> = {
239
245
  sm: 36,
240
246
  md: 44,
241
247
  lg: 48,
248
+ xl: 56,
242
249
  icon: 44,
243
250
  };
244
251
 
@@ -283,6 +290,7 @@ const NATIVE_CONTROL_SIZE = {
283
290
  sm: 'small',
284
291
  md: 'regular',
285
292
  lg: 'large',
293
+ xl: 'extraLarge',
286
294
  icon: 'regular',
287
295
  } as const;
288
296
 
@@ -85,6 +85,7 @@ const buttonGroupVariants = tv({
85
85
  sm: { root: 'rounded-lg' },
86
86
  md: { root: 'rounded-xl' },
87
87
  lg: { root: 'rounded-xl' },
88
+ xl: { root: 'rounded-2xl' },
88
89
  icon: { root: 'rounded-xl' },
89
90
  },
90
91
  fullWidth: {