panelui-native 0.90.0 → 0.92.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.
@@ -1,4 +1,5 @@
1
1
  import { memo, useEffect } from 'react';
2
+ import type { ViewProps } from 'react-native';
2
3
  import Animated, {
3
4
  cancelAnimation,
4
5
  useAnimatedStyle,
@@ -21,6 +22,14 @@ const RESTING_OPACITY = 0.7;
21
22
 
22
23
  export interface SkeletonProps {
23
24
  className?: string;
25
+ /**
26
+ * View style for the placeholder — a dimension computed at runtime, or one no
27
+ * utility class expresses. Ordinary sizing belongs in `className`, and a
28
+ * value here wins over the class that sets the same property.
29
+ *
30
+ * The pulse owns `opacity`; setting it here has no effect.
31
+ */
32
+ style?: ViewProps['style'];
24
33
  /**
25
34
  * What is loading, for a screen reader. Setting it makes this skeleton
26
35
  * announce as a busy status; leaving it unset keeps the placeholder out of
@@ -49,6 +58,7 @@ export interface SkeletonProps {
49
58
  */
50
59
  export const Skeleton = memo(function Skeleton({
51
60
  className,
61
+ style,
52
62
  label,
53
63
  }: SkeletonProps) {
54
64
  const reducedMotion = useReducedMotion();
@@ -81,7 +91,7 @@ export const Skeleton = memo(function Skeleton({
81
91
  accessibilityState={announced ? { busy: true } : undefined}
82
92
  accessibilityElementsHidden={!announced}
83
93
  importantForAccessibility={announced ? 'auto' : 'no-hide-descendants'}
84
- style={animatedStyle}
94
+ style={[style, animatedStyle]}
85
95
  className={cn('rounded-md bg-skeleton', className)}
86
96
  />
87
97
  );
package/src/index.ts CHANGED
@@ -22,7 +22,16 @@ export { Portal, PortalHost, PortalProvider } from './primitives/portal';
22
22
  export { Scrim, hasBlur, type ScrimProps } from './primitives/scrim';
23
23
  // The material iOS draws its floating controls in, with a solid fallback where
24
24
  // there is none. Public so a surface of your own gets the same treatment.
25
- export { Glass, hasGlass, type GlassProps, type GlassVariant, type GlassRadius } from './primitives/glass';
25
+ export {
26
+ Glass,
27
+ GlassContainer,
28
+ hasGlass,
29
+ useGlassMaterial,
30
+ type GlassProps,
31
+ type GlassContainerProps,
32
+ type GlassVariant,
33
+ type GlassRadius,
34
+ } from './primitives/glass';
26
35
  export { Text, type TextProps } from './primitives/text';
27
36
  export { Collapse, type CollapseProps } from './primitives/collapse';
28
37
  export {
@@ -218,6 +227,9 @@ export {
218
227
  type FabPlacement,
219
228
  type FabSize,
220
229
  type FabVariant,
230
+ type FabGroupLayout,
231
+ type FabMenuAppearance,
232
+ type FabMenuIconPlacement,
221
233
  } from './components/fab';
222
234
  export {
223
235
  Flow,
@@ -182,6 +182,27 @@ interface SwiftUIModifiers {
182
182
  * changing any code on a report of "the colour did nothing".
183
183
  */
184
184
  presentationBackground: (color: string) => unknown;
185
+ /**
186
+ * Greys a control out and stops it answering.
187
+ *
188
+ * Optional because the module is cast whole rather than feature-checked, and
189
+ * an older `@expo/ui` without it would otherwise be a crash instead of a
190
+ * menu row that is merely still tappable. Call it through a guard.
191
+ */
192
+ disabled?: (disabled: boolean) => unknown;
193
+ /**
194
+ * Fires when the view is put on screen, and when it is taken off again.
195
+ *
196
+ * The way to find out that a menu has opened. SwiftUI builds a menu's
197
+ * content only once it is presented, so an item inside one appears exactly
198
+ * when the menu does — which is the only signal the control gives, since it
199
+ * owns its open state and reports nothing about it.
200
+ *
201
+ * Optional for the same reason as `disabled`: the module is cast whole, and
202
+ * a version without these should lose the backdrop rather than crash.
203
+ */
204
+ onAppear?: (handler: () => void) => unknown;
205
+ onDisappear?: (handler: () => void) => unknown;
185
206
  }
186
207
 
187
208
  /**
@@ -241,6 +262,131 @@ export function getSwiftUI(): SwiftUIComponents | null {
241
262
  return swiftUI;
242
263
  }
243
264
 
265
+ /**
266
+ * A hosting boundary, as both toolkits declare it. The same three props matter
267
+ * on either side — see `NativeUIModule.Host` above for what each one costs.
268
+ */
269
+ type NativeHostComponent = ComponentType<{
270
+ children?: ReactNode;
271
+ matchContents?: boolean | { vertical?: boolean; horizontal?: boolean };
272
+ ignoreSafeArea?: 'all' | 'container' | 'keyboard';
273
+ colorScheme?: 'light' | 'dark';
274
+ style?: unknown;
275
+ }>;
276
+
277
+ type RNHostComponent = ComponentType<{
278
+ children?: ReactNode;
279
+ matchContents?: boolean;
280
+ style?: unknown;
281
+ }>;
282
+
283
+ /**
284
+ * SwiftUI's menu, and the button that fills a row of it.
285
+ *
286
+ * Separate from `getSwiftUI` on purpose: that resolver refuses a module with no
287
+ * popover in it, and a version that ships one control but not the other would
288
+ * take the menu down with it. Each native path asks only for what it needs.
289
+ */
290
+ interface SwiftUIMenuComponents {
291
+ Host: NativeHostComponent;
292
+ RNHostView: RNHostComponent;
293
+ /**
294
+ * `label` takes a React element as well as a string — the element is passed
295
+ * through a native slot and becomes the thing you press. That is what lets
296
+ * the trigger stay a real Fab rather than a platform button wearing its name.
297
+ */
298
+ Menu: ComponentType<{
299
+ label?: ReactNode;
300
+ children?: ReactNode;
301
+ modifiers?: unknown[];
302
+ }>;
303
+ Button: ComponentType<{
304
+ label?: string;
305
+ systemImage?: string;
306
+ role?: 'default' | 'cancel' | 'destructive';
307
+ onPress?: () => void;
308
+ modifiers?: unknown[];
309
+ }>;
310
+ }
311
+
312
+ let swiftUIMenuResolved = false;
313
+ let swiftUIMenu: SwiftUIMenuComponents | null = null;
314
+
315
+ export function getSwiftUIMenu(): SwiftUIMenuComponents | null {
316
+ if (swiftUIMenuResolved) return swiftUIMenu;
317
+ swiftUIMenuResolved = true;
318
+
319
+ if (Platform.OS !== 'ios') return null;
320
+
321
+ try {
322
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
323
+ const module = require('@expo/ui/swift-ui') as Partial<SwiftUIMenuComponents>;
324
+ swiftUIMenu =
325
+ module.Host && module.RNHostView && module.Menu && module.Button
326
+ ? (module as SwiftUIMenuComponents)
327
+ : null;
328
+ } catch {
329
+ swiftUIMenu = null;
330
+ }
331
+
332
+ return swiftUIMenu;
333
+ }
334
+
335
+ /**
336
+ * Compose's dropdown menu — the same instruction, answered by a different
337
+ * control.
338
+ *
339
+ * It is not the shape SwiftUI's menu is. This one is controlled: it takes the
340
+ * open state rather than owning it, and it splits the trigger and the items
341
+ * into named slots instead of reading the label off a prop. Both differences
342
+ * reach the caller, so they are written out here rather than smoothed over.
343
+ */
344
+ interface ComposeMenuComponents {
345
+ Host: NativeHostComponent;
346
+ RNHostView: RNHostComponent;
347
+ Text: ComponentType<{ children?: ReactNode; color?: string }>;
348
+ DropdownMenu: ComponentType<{
349
+ children?: ReactNode;
350
+ expanded?: boolean;
351
+ onDismissRequest?: () => void;
352
+ }> & {
353
+ Trigger: ComponentType<{ children?: ReactNode }>;
354
+ Items: ComponentType<{ children?: ReactNode }>;
355
+ };
356
+ DropdownMenuItem: ComponentType<{
357
+ children?: ReactNode;
358
+ enabled?: boolean;
359
+ onClick?: () => void;
360
+ elementColors?: { textColor?: string; disabledTextColor?: string };
361
+ }> & {
362
+ Text: ComponentType<{ children?: ReactNode }>;
363
+ LeadingIcon: ComponentType<{ children?: ReactNode }>;
364
+ };
365
+ }
366
+
367
+ let composeMenuResolved = false;
368
+ let composeMenu: ComposeMenuComponents | null = null;
369
+
370
+ export function getComposeMenu(): ComposeMenuComponents | null {
371
+ if (composeMenuResolved) return composeMenu;
372
+ composeMenuResolved = true;
373
+
374
+ if (Platform.OS !== 'android') return null;
375
+
376
+ try {
377
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
378
+ const module = require('@expo/ui/jetpack-compose') as Partial<ComposeMenuComponents>;
379
+ composeMenu =
380
+ module.Host && module.RNHostView && module.DropdownMenu && module.DropdownMenuItem
381
+ ? (module as ComposeMenuComponents)
382
+ : null;
383
+ } catch {
384
+ composeMenu = null;
385
+ }
386
+
387
+ return composeMenu;
388
+ }
389
+
244
390
  let modifiersResolved = false;
245
391
  let modifiers: SwiftUIModifiers | null = null;
246
392
 
@@ -36,13 +36,18 @@
36
36
  * its own corners — clipping a square one to a rounded parent throws away the
37
37
  * lit edge that makes it read as glass.
38
38
  *
39
+ * `interactive` is the one exception to the layer: the platform only animates
40
+ * the glass under a touch it can see, so with it on the material is the box
41
+ * and the children are hosted inside it, in normal flow. Reach for it when
42
+ * the glass *is* the button.
43
+ *
39
44
  * ## Do not fade it
40
45
  *
41
46
  * Setting `opacity` to `0` on the material or on anything above it stops it
42
47
  * rendering at all, and it does not come back when the opacity does. Move it,
43
48
  * or unmount it; never animate it out.
44
49
  */
45
- import type { ComponentType, ReactNode } from 'react';
50
+ import { forwardRef, type ComponentType, type ReactNode } from 'react';
46
51
  import { Platform, StyleSheet, View, type StyleProp, type ViewProps, type ViewStyle } from 'react-native';
47
52
  import { useThemeMode } from '../theme/use-theme';
48
53
  import { cn } from '../utils/cn';
@@ -71,18 +76,27 @@ function shapeOf(radius: GlassRadius | undefined) {
71
76
  };
72
77
  }
73
78
 
74
- interface GlassViewProps {
79
+ interface GlassViewProps extends ViewProps {
75
80
  glassEffectStyle?: GlassVariant | 'none';
76
81
  tintColor?: string;
82
+ isInteractive?: boolean;
77
83
  colorScheme?: 'auto' | 'light' | 'dark';
78
84
  style?: StyleProp<ViewStyle>;
79
- pointerEvents?: ViewProps['pointerEvents'];
80
85
  children?: ReactNode;
81
86
  }
82
87
 
88
+ interface GlassContainerViewProps extends ViewProps {
89
+ spacing?: number;
90
+ ref?: React.Ref<View>;
91
+ }
92
+
93
+ interface GlassModule {
94
+ GlassView: ComponentType<GlassViewProps>;
95
+ GlassContainer: ComponentType<GlassContainerViewProps>;
96
+ }
97
+
83
98
  /**
84
- * `expo-glass-effect`'s GlassView, or null when the material cannot be drawn
85
- * here.
99
+ * `expo-glass-effect`, or null when the material cannot be drawn here.
86
100
  *
87
101
  * Resolved once at module load, behind three gates that all have to pass. The
88
102
  * package is optional, so the require can fail; the API is missing from some
@@ -91,7 +105,7 @@ interface GlassViewProps {
91
105
  * cheaper than a try/catch on every render, and there is no answer that can
92
106
  * change while the process is alive.
93
107
  */
94
- const GlassView: ComponentType<GlassViewProps> | null = (() => {
108
+ const glassModule: GlassModule | null = (() => {
95
109
  if (Platform.OS !== 'ios') return null;
96
110
  try {
97
111
  // eslint-disable-next-line @typescript-eslint/no-require-imports
@@ -102,12 +116,15 @@ const GlassView: ComponentType<GlassViewProps> | null = (() => {
102
116
  if (typeof mod?.isLiquidGlassAvailable === 'function' && !mod.isLiquidGlassAvailable()) {
103
117
  return null;
104
118
  }
105
- return (mod?.GlassView as ComponentType<GlassViewProps>) ?? null;
119
+ return mod?.GlassView ? (mod as GlassModule) : null;
106
120
  } catch {
107
121
  return null;
108
122
  }
109
123
  })();
110
124
 
125
+ const GlassView = glassModule?.GlassView ?? null;
126
+ const GlassContainerView = glassModule?.GlassContainer ?? null;
127
+
111
128
  /**
112
129
  * True when the real material can be drawn — for a caller that wants to know
113
130
  * before it commits to a look. It says nothing about Reduce Transparency, which
@@ -115,6 +132,21 @@ const GlassView: ComponentType<GlassViewProps> | null = (() => {
115
132
  */
116
133
  export const hasGlass = GlassView !== null;
117
134
 
135
+ /**
136
+ * Whether the material will actually be drawn right now: the API is present
137
+ * *and* the user has not switched Reduce Transparency on.
138
+ *
139
+ * For a component that changes shape around the material — dropping a fill, a
140
+ * border or a shadow the glass replaces — so that it makes the same decision
141
+ * `Glass` makes and never strips the fill while leaving nothing behind it.
142
+ */
143
+ export function useGlassMaterial(): boolean {
144
+ const reduceTransparency = useReduceTransparency();
145
+ // Not knowing yet counts as "do not draw it": the material arriving a frame
146
+ // late is invisible, and one flashing at somebody who opted out is not.
147
+ return hasGlass && reduceTransparency === false;
148
+ }
149
+
118
150
  export interface GlassProps extends ViewProps {
119
151
  /**
120
152
  * How much of what is behind shows through. `regular` is the everyday
@@ -129,6 +161,16 @@ export interface GlassProps extends ViewProps {
129
161
  * to a rounded parent throws away the lit edge that makes it read as glass.
130
162
  */
131
163
  radius?: GlassRadius;
164
+ /**
165
+ * Let the material answer touch the way the platform's own controls do:
166
+ * it brightens and swells under the finger and the highlight follows it.
167
+ *
168
+ * For a material that *is* a button. The platform only tracks touches that
169
+ * land inside the glass view, so with this on the content is hosted inside
170
+ * the material rather than above it — a pressable written as a child still
171
+ * gets its press, and the glass reacts to the same touch.
172
+ */
173
+ interactive?: boolean;
132
174
  /** Applied only when the material cannot be drawn. Give it a real surface. */
133
175
  fallbackClassName?: string;
134
176
  className?: string;
@@ -138,13 +180,13 @@ export function Glass({
138
180
  variant = 'regular',
139
181
  tint,
140
182
  radius,
183
+ interactive = false,
141
184
  fallbackClassName = 'bg-card',
142
185
  className,
143
186
  children,
144
187
  style,
145
188
  ...props
146
189
  }: GlassProps) {
147
- const reduceTransparency = useReduceTransparency();
148
190
  /*
149
191
  * Which appearance the material is drawn in, from the app's theme rather
150
192
  * than the phone's.
@@ -155,11 +197,33 @@ export function Glass({
155
197
  * appearance never moved.
156
198
  */
157
199
  const { mode } = useThemeMode();
158
- // Not knowing yet counts as "do not draw it": the material arriving a frame
159
- // late is invisible, and one flashing at somebody who opted out is not.
160
- const material = GlassView !== null && reduceTransparency === false;
200
+ const material = useGlassMaterial();
161
201
  const shape = shapeOf(radius);
162
202
 
203
+ /*
204
+ * Interactive, the material is the box rather than a layer in it. The
205
+ * platform only tracks a touch that lands inside the glass view, so the
206
+ * content has to be hosted in it — and hosted in normal flow, so that a
207
+ * box sized by its content still is. A layer pinned to the box's edges
208
+ * could not size it, and a button as wide as its label would collapse to
209
+ * its minimum. The classes go on a view inside, because the native view is
210
+ * not a styled one; only the positional style stays on the outside.
211
+ */
212
+ if (material && GlassView && interactive) {
213
+ return (
214
+ <GlassView
215
+ glassEffectStyle={variant}
216
+ tintColor={tint}
217
+ isInteractive
218
+ colorScheme={mode}
219
+ style={[shape, style]}
220
+ {...props}
221
+ >
222
+ <View className={className}>{children}</View>
223
+ </GlassView>
224
+ );
225
+ }
226
+
163
227
  return (
164
228
  <View
165
229
  className={cn('overflow-hidden', material ? null : fallbackClassName, className)}
@@ -181,3 +245,44 @@ export function Glass({
181
245
  }
182
246
 
183
247
  Glass.displayName = 'Glass';
248
+
249
+ export interface GlassContainerProps extends ViewProps {
250
+ /**
251
+ * How close two pieces of glass have to be before they merge, in points.
252
+ * Inside it their edges flow into one another; a piece moving past
253
+ * another blends with it and pulls free as it leaves.
254
+ */
255
+ spacing?: number;
256
+ className?: string;
257
+ }
258
+
259
+ /**
260
+ * Lets the glass inside it merge.
261
+ *
262
+ * On its own each piece of the material is a separate object with its own
263
+ * lit edge. Inside a container, pieces within `spacing` of each other flow
264
+ * together — which is what makes a button that opens into other buttons look
265
+ * like one thing dividing rather than several things arriving. A plain view
266
+ * wherever the material is not drawn, so it can be written unconditionally.
267
+ */
268
+ export const GlassContainer = forwardRef<View, GlassContainerProps>(
269
+ ({ spacing, className, style, children, ...props }, ref) => {
270
+ const material = useGlassMaterial();
271
+ if (material && GlassContainerView) {
272
+ // The native container is not a styled view, so the classes go on a
273
+ // view inside it and only the positional style stays on the outside.
274
+ return (
275
+ <GlassContainerView ref={ref} spacing={spacing} style={style} {...props}>
276
+ <View className={className}>{children}</View>
277
+ </GlassContainerView>
278
+ );
279
+ }
280
+ return (
281
+ <View ref={ref} className={className} style={style} {...props}>
282
+ {children}
283
+ </View>
284
+ );
285
+ }
286
+ );
287
+
288
+ GlassContainer.displayName = 'GlassContainer';