@oxyhq/services 22.8.4 → 22.8.6

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 (51) hide show
  1. package/lib/commonjs/ui/components/BottomSheetRouter.js +68 -23
  2. package/lib/commonjs/ui/components/BottomSheetRouter.js.map +1 -1
  3. package/lib/commonjs/ui/components/ProfileButton.js +4 -10
  4. package/lib/commonjs/ui/components/ProfileButton.js.map +1 -1
  5. package/lib/commonjs/ui/components/fileManagement/UploadPreview.js +1 -1
  6. package/lib/commonjs/ui/components/fileManagement/UploadPreview.js.map +1 -1
  7. package/lib/commonjs/ui/navigation/routes.js +7 -4
  8. package/lib/commonjs/ui/navigation/routes.js.map +1 -1
  9. package/lib/commonjs/ui/screens/FileManagementScreen.js +49 -173
  10. package/lib/commonjs/ui/screens/FileManagementScreen.js.map +1 -1
  11. package/lib/commonjs/ui/screens/fileManagement/FileListSection.js +3 -0
  12. package/lib/commonjs/ui/screens/fileManagement/FileListSection.js.map +1 -1
  13. package/lib/commonjs/ui/screens/fileManagement/PhotoPickerSection.js +111 -217
  14. package/lib/commonjs/ui/screens/fileManagement/PhotoPickerSection.js.map +1 -1
  15. package/lib/module/ui/components/BottomSheetRouter.js +69 -24
  16. package/lib/module/ui/components/BottomSheetRouter.js.map +1 -1
  17. package/lib/module/ui/components/ProfileButton.js +3 -10
  18. package/lib/module/ui/components/ProfileButton.js.map +1 -1
  19. package/lib/module/ui/components/fileManagement/UploadPreview.js +1 -1
  20. package/lib/module/ui/components/fileManagement/UploadPreview.js.map +1 -1
  21. package/lib/module/ui/navigation/routes.js +7 -4
  22. package/lib/module/ui/navigation/routes.js.map +1 -1
  23. package/lib/module/ui/screens/FileManagementScreen.js +50 -174
  24. package/lib/module/ui/screens/FileManagementScreen.js.map +1 -1
  25. package/lib/module/ui/screens/fileManagement/FileListSection.js +3 -0
  26. package/lib/module/ui/screens/fileManagement/FileListSection.js.map +1 -1
  27. package/lib/module/ui/screens/fileManagement/PhotoPickerSection.js +112 -218
  28. package/lib/module/ui/screens/fileManagement/PhotoPickerSection.js.map +1 -1
  29. package/lib/typescript/commonjs/ui/components/BottomSheetRouter.d.ts.map +1 -1
  30. package/lib/typescript/commonjs/ui/components/ProfileButton.d.ts.map +1 -1
  31. package/lib/typescript/commonjs/ui/navigation/routes.d.ts +12 -0
  32. package/lib/typescript/commonjs/ui/navigation/routes.d.ts.map +1 -1
  33. package/lib/typescript/commonjs/ui/screens/FileManagementScreen.d.ts.map +1 -1
  34. package/lib/typescript/commonjs/ui/screens/fileManagement/FileListSection.d.ts.map +1 -1
  35. package/lib/typescript/commonjs/ui/screens/fileManagement/PhotoPickerSection.d.ts.map +1 -1
  36. package/lib/typescript/module/ui/components/BottomSheetRouter.d.ts.map +1 -1
  37. package/lib/typescript/module/ui/components/ProfileButton.d.ts.map +1 -1
  38. package/lib/typescript/module/ui/navigation/routes.d.ts +12 -0
  39. package/lib/typescript/module/ui/navigation/routes.d.ts.map +1 -1
  40. package/lib/typescript/module/ui/screens/FileManagementScreen.d.ts.map +1 -1
  41. package/lib/typescript/module/ui/screens/fileManagement/FileListSection.d.ts.map +1 -1
  42. package/lib/typescript/module/ui/screens/fileManagement/PhotoPickerSection.d.ts.map +1 -1
  43. package/package.json +1 -1
  44. package/src/ui/components/BottomSheetRouter.tsx +81 -28
  45. package/src/ui/components/ProfileButton.tsx +2 -14
  46. package/src/ui/components/fileManagement/UploadPreview.tsx +1 -1
  47. package/src/ui/navigation/__tests__/getSheetConfig.test.ts +36 -0
  48. package/src/ui/navigation/routes.ts +19 -4
  49. package/src/ui/screens/FileManagementScreen.tsx +36 -174
  50. package/src/ui/screens/fileManagement/FileListSection.tsx +1 -0
  51. package/src/ui/screens/fileManagement/PhotoPickerSection.tsx +127 -223
@@ -13,6 +13,7 @@ import {
13
13
  } from 'react-native';
14
14
  import { Image as ExpoImage } from 'expo-image';
15
15
  import { Ionicons, MaterialCommunityIcons } from '@expo/vector-icons';
16
+ import { BREAKPOINTS } from '@oxyhq/bloom/styles';
16
17
  import type { FileMetadata } from '@oxyhq/core';
17
18
  import { computePhotoGridLayout } from './photoGridLayout';
18
19
 
@@ -91,20 +92,18 @@ const hapticNotification = async (type: HapticNotification): Promise<void> => {
91
92
  };
92
93
 
93
94
  /**
94
- * The bottom sheet renders below the status bar already (its `maxHeight` is
95
- * capped by `SCREEN_HEIGHT - insets.top`), so the picker MUST NOT add an extra
96
- * safe-area inset to the header. Header zone from the sheet top:
97
- * 28dp drag-handle hit area floats at the very top of the sheet
98
- * 56dp app bar sits immediately below the handle
99
- * Grid content is pushed down by the full header zone + 4dp of breathing room
100
- * so the first row clears the translucent header. These are fixed layout
101
- * constants — the equivalent `pt-[28px]` / `min-h-[84px]` / `pt-[88px]` utility
102
- * classes in the JSX MUST stay in sync with them.
95
+ * The picker renders inside a Bloom `<Dialog>` (bottom-sheet on narrow, centered
96
+ * card on `md+`). The Dialog owns its own chrome: it draws its OWN drag handle
97
+ * in bottom placement (a floating 28dp pill that paints ABOVE this header via a
98
+ * higher z-index) and none in center placement so the picker's translucent
99
+ * header no longer reserves the handle strip itself. The header is just the 56dp
100
+ * app bar; grid content is pushed down by the app bar + 4dp of breathing room so
101
+ * the first row clears it. These are fixed layout constants — the equivalent
102
+ * `min-h-[56px]` / `pt-[60px]` utility classes in the JSX MUST stay in sync.
103
103
  */
104
- const HANDLE_ZONE = 28;
105
104
  const APP_BAR_HEIGHT = 56;
106
- const HEADER_HEIGHT = HANDLE_ZONE + APP_BAR_HEIGHT; // 84
107
- const GRID_CONTENT_PADDING_TOP = HEADER_HEIGHT + 4; // 88
105
+ const HEADER_HEIGHT = APP_BAR_HEIGHT; // 56
106
+ const GRID_CONTENT_PADDING_TOP = HEADER_HEIGHT + 4; // 60
108
107
 
109
108
  /**
110
109
  * Props for the dedicated photo picker view. Used by FileManagementScreen
@@ -138,8 +137,8 @@ export interface PhotoPickerViewProps {
138
137
  }
139
138
 
140
139
  /**
141
- * Photo-cell animation tuning. The animated cell uses RN's own `Animated`
142
- * (NOT Reanimated): its entrance is a staggered opacity fade — replacing the
140
+ * Photo-cell animation tuning. The cell uses RN's own `Animated` (NOT
141
+ * Reanimated): its entrance is a staggered opacity fade — replacing the
143
142
  * Reanimated `entering={FadeIn}` LAYOUT animation, which cannot load on the
144
143
  * RN-Web build (no `react-native-worklets` babel plugin) and only warns +
145
144
  * no-ops there. RN `Animated` runs cross-platform: native-driven on device,
@@ -155,77 +154,13 @@ const RING_PULSE_UP_MS = 110;
155
154
  const RING_SPRING_FRICTION = 9;
156
155
  const RING_SPRING_TENSION = 120;
157
156
  /**
158
- * Inline mirror of the `rounded-radius-8 border-[3px]` classes the static ring
159
- * carries. The animated ring MUST use inline `style` (its animated transform
160
- * has to be inline, and NativeWind `className` interop is not guaranteed on an
161
- * `Animated.View` under the RN-Web build), so these keep it pixel-identical to
162
- * the static ring.
157
+ * The selection ring is an `Animated.View` (its pulse transform must be inline,
158
+ * and `className` interop on `Animated.View` is not guaranteed under the RN-Web
159
+ * build), so these mirror the `rounded-radius-8 border-[3px]` design tokens.
163
160
  */
164
161
  const CELL_CORNER_RADIUS = 8; // `rounded-radius-8`
165
162
  const SELECTION_RING_WIDTH = 3; // `border-[3px]`
166
163
 
167
- /**
168
- * A single photo cell. Memoized so re-renders during selection only touch
169
- * affected cells — selection of one photo must not redraw the whole grid.
170
- *
171
- * Apple Photos pattern: when any cell is selected, *non-selected* siblings
172
- * fade to 0.6 opacity to focus attention on the active selection.
173
- */
174
- /**
175
- * Shared cell chrome (image, dim, badge). Ring border is injected by the
176
- * static vs animated wrappers.
177
- */
178
- function PhotoPickerCellContent(props: {
179
- photo: FileMetadata;
180
- dim: boolean;
181
- isSelected: boolean;
182
- selectionIndex: number;
183
- primaryColor: string;
184
- thumbUrl: string | undefined;
185
- ring: React.ReactNode;
186
- }) {
187
- const {
188
- photo, dim, isSelected, selectionIndex, primaryColor, thumbUrl, ring,
189
- } = props;
190
-
191
- return (
192
- <>
193
- {/* Inline `flex: 1` is load-bearing on web and NOT interchangeable with
194
- the `flex-1` class: on RN-Web the NativeWind/react-native-css `flex-1`
195
- utility does not reliably resolve to `flex: 1` (the class is present in
196
- the DOM but inert — RN-Web's base View atomic styles out-rank it), so
197
- this content View collapses to 0 height and the image (`height: 100%` of
198
- a 0-height box) becomes invisible — every tile blank. Inline style always
199
- wins, so it is the sanctioned exception here. Native is unaffected. */}
200
- <View
201
- style={{ flex: 1 }}
202
- className={`flex-1 rounded-radius-8 overflow-hidden bg-[#111111]${dim ? ' opacity-60' : ''}`}
203
- >
204
- <ExpoImage
205
- source={{ uri: thumbUrl }}
206
- style={{ width: '100%', height: '100%' }}
207
- contentFit="cover"
208
- transition={120}
209
- cachePolicy="memory-disk"
210
- accessibilityLabel={photo.filename}
211
- />
212
- </View>
213
- {ring}
214
- {isSelected && (
215
- <View
216
- pointerEvents="none"
217
- className="absolute top-1.5 right-1.5 min-w-[22px] h-[22px] px-1.5 rounded-full items-center justify-center"
218
- style={{ backgroundColor: primaryColor }}
219
- >
220
- <Text className="text-white text-[12px] font-bold leading-[14px]">
221
- {selectionIndex > 0 ? String(selectionIndex) : ''}
222
- </Text>
223
- </View>
224
- )}
225
- </>
226
- );
227
- }
228
-
229
164
  type PhotoPickerCellProps = {
230
165
  photo: FileMetadata;
231
166
  size: number;
@@ -243,65 +178,33 @@ type PhotoPickerCellProps = {
243
178
  a11yLabel: string;
244
179
  };
245
180
 
246
- const PhotoPickerCellStatic = React.memo(function PhotoPickerCellStatic(props: PhotoPickerCellProps) {
247
- const {
248
- photo, size, marginRight, marginBottom, isSelected, selectionIndex,
249
- dim, primaryColor, thumbUrl, onPress, onLongPress, a11yLabel,
250
- } = props;
251
-
252
- const cellWrapperStyle = {
253
- width: size,
254
- height: size,
255
- marginRight,
256
- marginBottom,
257
- };
258
-
259
- const ring = isSelected ? (
260
- <View
261
- pointerEvents="none"
262
- className="absolute inset-0 rounded-radius-8 border-[3px]"
263
- style={{ borderColor: primaryColor }}
264
- />
265
- ) : null;
266
-
267
- return (
268
- <TouchableOpacity
269
- activeOpacity={0.85}
270
- onPress={onPress}
271
- onLongPress={onLongPress}
272
- className="relative"
273
- style={cellWrapperStyle}
274
- accessibilityRole="button"
275
- accessibilityLabel={a11yLabel}
276
- accessibilityState={{ selected: isSelected }}
277
- >
278
- <PhotoPickerCellContent
279
- photo={photo}
280
- dim={dim}
281
- isSelected={isSelected}
282
- selectionIndex={selectionIndex}
283
- primaryColor={primaryColor}
284
- thumbUrl={thumbUrl}
285
- ring={ring}
286
- />
287
- </TouchableOpacity>
288
- );
289
- });
290
-
291
- const PhotoPickerCellAnimated = React.memo(function PhotoPickerCellAnimated(props: PhotoPickerCellProps) {
181
+ /**
182
+ * A single photo cell. Memoized so re-renders during selection only touch
183
+ * affected cells — selecting one photo must not redraw the whole grid.
184
+ *
185
+ * Apple Photos pattern: when any cell is selected, *non-selected* siblings fade
186
+ * to 0.6 opacity to focus attention on the active selection. A staggered
187
+ * entrance fade plays on mount; a spring pulse plays each time a cell enters the
188
+ * selected state. Both animations are pinned off under reduce-motion, so the
189
+ * one component covers both the animated and the reduced-motion paths.
190
+ */
191
+ const PhotoPickerCell = React.memo(function PhotoPickerCell(props: PhotoPickerCellProps) {
292
192
  const {
293
193
  photo, size, marginRight, marginBottom, isSelected, selectionIndex,
294
- dim, primaryColor, thumbUrl, enterIndex, onPress, onLongPress, a11yLabel,
194
+ dim, primaryColor, thumbUrl, enterIndex, reduceMotion,
195
+ onPress, onLongPress, a11yLabel,
295
196
  } = props;
296
197
 
297
198
  const delay = Math.min(enterIndex * STAGGER_PER_CELL_MS, MAX_TOTAL_STAGGER_MS);
298
199
 
299
- // Entrance: a per-cell opacity fade, staggered by the cell's grid index.
300
- // `useNativeDriver` is only available on native — on web the animation is
301
- // JS-driven, which still plays the fade (unlike Reanimated layout entering,
302
- // which no-ops on the plugin-less RN-Web build).
303
- const opacity = useRef(new Animated.Value(0)).current;
200
+ // Entrance fade, staggered by the cell's grid index. Reduce-motion pins
201
+ // opacity at 1 and never animates.
202
+ const opacity = useRef(new Animated.Value(reduceMotion ? 1 : 0)).current;
304
203
  useEffect(() => {
204
+ if (reduceMotion) {
205
+ opacity.setValue(1);
206
+ return;
207
+ }
305
208
  const entrance = Animated.timing(opacity, {
306
209
  toValue: 1,
307
210
  duration: ENTRANCE_DURATION_MS,
@@ -310,16 +213,18 @@ const PhotoPickerCellAnimated = React.memo(function PhotoPickerCellAnimated(prop
310
213
  });
311
214
  entrance.start();
312
215
  return () => entrance.stop();
313
- }, [opacity, delay]);
216
+ }, [opacity, delay, reduceMotion]);
314
217
 
315
- // Ring pulse: a quick scale bump each time a cell transitions INTO the
316
- // selected state, springing back to rest.
218
+ // Selection-ring pulse: a quick scale bump each time the cell transitions
219
+ // INTO the selected state, springing back to rest. Reduce-motion holds the
220
+ // ring at rest scale (still tracks selection state so a later un-reduce does
221
+ // not fire a stale pulse).
317
222
  const ringScale = useRef(new Animated.Value(1)).current;
318
223
  const prevSelected = useRef(isSelected);
319
224
  useEffect(() => {
320
225
  if (prevSelected.current === isSelected) return;
321
226
  prevSelected.current = isSelected;
322
- if (!isSelected) {
227
+ if (reduceMotion || !isSelected) {
323
228
  ringScale.setValue(1);
324
229
  return;
325
230
  }
@@ -338,33 +243,13 @@ const PhotoPickerCellAnimated = React.memo(function PhotoPickerCellAnimated(prop
338
243
  ]);
339
244
  pulse.start();
340
245
  return () => pulse.stop();
341
- }, [isSelected, ringScale]);
342
-
343
- // The animated ring reads `ringScale`; its border/radius/position are inline
344
- // `style` because NativeWind `className` interop is not guaranteed on an
345
- // `Animated.View` under the RN-Web build.
346
- const ring = isSelected ? (
347
- <Animated.View
348
- pointerEvents="none"
349
- style={{
350
- position: 'absolute',
351
- top: 0,
352
- right: 0,
353
- bottom: 0,
354
- left: 0,
355
- borderRadius: CELL_CORNER_RADIUS,
356
- borderWidth: SELECTION_RING_WIDTH,
357
- borderColor: primaryColor,
358
- transform: [{ scale: ringScale }],
359
- }}
360
- />
361
- ) : null;
246
+ }, [isSelected, ringScale, reduceMotion]);
362
247
 
363
248
  return (
364
- // Animated wrapper carries the entrance opacity + layout box as inline
365
- // `style` (animated values must be inline, and className interop on
366
- // Animated.View is not guaranteed on RN-Web). `className` stays on the
367
- // non-animated TouchableOpacity + content below.
249
+ // The animated wrapper carries the entrance opacity + the layout box as
250
+ // inline `style`: animated values must be inline, and the box's
251
+ // size/margins are runtime pixels className cannot express. `className`
252
+ // drives the static chrome below (`flex-1` fills this fixed-size box).
368
253
  <Animated.View
369
254
  style={{
370
255
  width: size,
@@ -375,44 +260,59 @@ const PhotoPickerCellAnimated = React.memo(function PhotoPickerCellAnimated(prop
375
260
  opacity,
376
261
  }}
377
262
  >
378
- {/* Inline `flex: 1` (not just `className="flex-1"`): on RN-Web the
379
- `flex-1` utility is inert on this TouchableOpacity, so the button would
380
- collapse to 0 height inside the fixed-size wrapper and hide the tile.
381
- See PhotoPickerCellContent for the full rationale. */}
382
263
  <TouchableOpacity
383
264
  activeOpacity={0.85}
384
265
  onPress={onPress}
385
266
  onLongPress={onLongPress}
386
- style={{ flex: 1 }}
387
267
  className="flex-1"
388
268
  accessibilityRole="button"
389
269
  accessibilityLabel={a11yLabel}
390
270
  accessibilityState={{ selected: isSelected }}
391
271
  >
392
- <PhotoPickerCellContent
393
- photo={photo}
394
- dim={dim}
395
- isSelected={isSelected}
396
- selectionIndex={selectionIndex}
397
- primaryColor={primaryColor}
398
- thumbUrl={thumbUrl}
399
- ring={ring}
400
- />
272
+ <View
273
+ className={`flex-1 rounded-radius-8 overflow-hidden bg-[#111111]${dim ? ' opacity-60' : ''}`}
274
+ >
275
+ <ExpoImage
276
+ source={{ uri: thumbUrl }}
277
+ style={{ width: '100%', height: '100%' }}
278
+ contentFit="cover"
279
+ transition={120}
280
+ cachePolicy="memory-disk"
281
+ accessibilityLabel={photo.filename}
282
+ />
283
+ </View>
284
+ {isSelected && (
285
+ <Animated.View
286
+ pointerEvents="none"
287
+ style={{
288
+ position: 'absolute',
289
+ top: 0,
290
+ right: 0,
291
+ bottom: 0,
292
+ left: 0,
293
+ borderRadius: CELL_CORNER_RADIUS,
294
+ borderWidth: SELECTION_RING_WIDTH,
295
+ borderColor: primaryColor,
296
+ transform: [{ scale: ringScale }],
297
+ }}
298
+ />
299
+ )}
300
+ {isSelected && (
301
+ <View
302
+ pointerEvents="none"
303
+ className="absolute top-1.5 right-1.5 min-w-[22px] h-[22px] px-1.5 rounded-full items-center justify-center"
304
+ style={{ backgroundColor: primaryColor }}
305
+ >
306
+ <Text className="text-white text-[12px] font-bold leading-[14px]">
307
+ {selectionIndex > 0 ? String(selectionIndex) : ''}
308
+ </Text>
309
+ </View>
310
+ )}
401
311
  </TouchableOpacity>
402
312
  </Animated.View>
403
313
  );
404
314
  });
405
315
 
406
- const PhotoPickerCell = React.memo(function PhotoPickerCell(props: PhotoPickerCellProps) {
407
- // reduceMotion → the static (unanimated) cell on every platform. Otherwise
408
- // the RN-`Animated` cell, which now plays on web too (no Reanimated layout
409
- // entering, so no plugin-less RN-Web warning / no-op).
410
- if (props.reduceMotion) {
411
- return <PhotoPickerCellStatic {...props} />;
412
- }
413
- return <PhotoPickerCellAnimated {...props} />;
414
- });
415
-
416
316
  const PhotoPickerView: React.FC<PhotoPickerViewProps> = ({
417
317
  photos,
418
318
  selectedIds,
@@ -448,13 +348,33 @@ const PhotoPickerView: React.FC<PhotoPickerViewProps> = ({
448
348
  // then snaps to the sheet width once `onLayout` reports it. The measured
449
349
  // wrapper carries ONLY a `style` (no `className`) to maximise the chance
450
350
  // `onLayout` fires on web.
451
- const { width: windowWidth } = useWindowDimensions();
351
+ const { width: windowWidth, height: windowHeight } = useWindowDimensions();
452
352
  const [gridWidth, setGridWidth] = useState(0);
453
353
  const onRootLayout = useCallback((e: LayoutChangeEvent) => {
454
354
  const w = Math.round(e.nativeEvent.layout.width);
455
355
  setGridWidth((prev) => (prev === w ? prev : w));
456
356
  }, []);
457
357
 
358
+ // The hosting Dialog uses `{ base: 'bottom', md: 'center' }`. In BOTH
359
+ // placements the panel/sheet has no intrinsic height that a `flex: 1` root
360
+ // could fill down to the FlatList: the centered card hugs its content, and
361
+ // the bottom sheet's own `maxHeight` (several flex levels above this root)
362
+ // does not propagate a definite bound through the chain. So the root owns a
363
+ // `maxHeight` DIRECTLY — the FlatList then scrolls within it when the grid is
364
+ // long, and the panel hugs its content when the grid is short (no huge empty
365
+ // panel). The cap is tighter for the centered card (a floating modal reads
366
+ // better below full height); the bottom sheet may run taller. Keep this
367
+ // breakpoint in sync with `DIALOG_PLACEMENT` in BottomSheetRouter.
368
+ const isCenteredPanel = windowWidth >= BREAKPOINTS.md;
369
+ const rootStyle = useMemo(
370
+ () => ({
371
+ maxHeight: isCenteredPanel
372
+ ? Math.min(Math.round(windowHeight * 0.85), 720)
373
+ : Math.round(windowHeight * 0.9),
374
+ }),
375
+ [isCenteredPanel, windowHeight],
376
+ );
377
+
458
378
  const effectiveWidth = gridWidth > 0 ? gridWidth : windowWidth;
459
379
  const { columns, cellSize, gutter } = useMemo(
460
380
  () => computePhotoGridLayout(effectiveWidth),
@@ -584,27 +504,19 @@ const PhotoPickerView: React.FC<PhotoPickerViewProps> = ({
584
504
 
585
505
  return (
586
506
  // Measured wrapper: `style`-only (no className) so RN-Web fires onLayout.
587
- // The sheet is `scrollable=false` (the FlatList owns scrolling) and clamped by
588
- // its `maxHeight`; every flex link between it and the FlatList must SHRINK into
589
- // that clamp so the FlatList gets a bounded scroll area (else the list grows to
590
- // its content and is clipped — "renders half", no scroll).
591
- //
592
- // CRITICAL (web): shrink-into-clamp must be driven through `style`, NOT
593
- // `className="flex-1"`. On RN-Web every View carries a base atomic class that
594
- // hard-sets `flex-shrink: 0; flex-basis: auto` (and `min-height: 0`). The
595
- // `flex-1` utility is unreliable against it — depending on the consumer's web
596
- // pipeline it either never reaches the DOM or is out-ranked by that base reset,
597
- // so a className'd View keeps `flex-shrink: 0`, never shrinks, and grows to its
598
- // content (the list overflows the clamp and is clipped — "renders half", no
599
- // scroll). RN-Web emits inline `style` flex as a HIGHER-precedence atomic class
600
- // that beats the base reset, so inline `flex: 1` reliably yields `flex: 1 1 0%`.
601
- // No explicit `min-height: 0` is needed: RN-Web's base View already sets it (and
602
- // native Yoga defaults min to 0), so `flex: 1` alone shrinks into the clamp.
603
- <View style={{ flex: 1 }} onLayout={onRootLayout}>
604
- <View className="flex-1 bg-black" style={{ flex: 1 }}>
605
- {/* Photo grid (renders behind translucent header) */}
507
+ // `rootStyle` is placement-aware a `maxHeight` cap that gives the
508
+ // FlatList a bounded scroll region while letting a short grid hug its
509
+ // content (see the `rootStyle` derivation). It must NOT carry a className
510
+ // (that can suppress web `onLayout`).
511
+ <View style={rootStyle} onLayout={onRootLayout}>
512
+ {/* `min-h-0` lets this flex child shrink below its content on web so
513
+ the FlatList gets a bounded scroll region inside the height-capped
514
+ root (the CSS flexbox `min-height:auto` trap; a no-op on native,
515
+ where Yoga already defaults min-height to 0). */}
516
+ <View className="flex-1 min-h-0 bg-black">
517
+ {/* Photo grid (renders behind the translucent header). */}
606
518
  {isEmpty ? (
607
- <View className="flex-1 items-center justify-center px-space-32 pt-[88px]">
519
+ <View className="flex-1 items-center justify-center px-space-32 pt-[60px]">
608
520
  <View className="opacity-30 mb-space-16">
609
521
  <MaterialCommunityIcons name="image-outline" size={64} color="#FFFFFF" />
610
522
  </View>
@@ -644,9 +556,8 @@ const PhotoPickerView: React.FC<PhotoPickerViewProps> = ({
644
556
  renderItem={renderItem}
645
557
  keyExtractor={keyExtractor}
646
558
  numColumns={columns}
647
- className="flex-1"
648
- style={{ flex: 1 }}
649
- contentContainerClassName="pt-[88px] pb-space-24"
559
+ className="flex-1 min-h-0"
560
+ contentContainerClassName="pt-[60px] pb-space-24"
650
561
  showsVerticalScrollIndicator={false}
651
562
  refreshControl={
652
563
  <RefreshControl
@@ -666,19 +577,12 @@ const PhotoPickerView: React.FC<PhotoPickerViewProps> = ({
666
577
  />
667
578
  )}
668
579
 
669
- {/* Translucent black header. The bottom sheet already sits below
670
- the status bar, so we do NOT add `insets.top` that would
671
- double-pad. `pt-[28px]` clears the 28dp drag handle floating
672
- at the top of the sheet; `min-h-[84px]` = handle + app bar.
673
- `position/top/left/right/zIndex` are ALSO set inline: on RN-Web
674
- the `absolute`/`top-0`/`z-30` utility classes are inert (RN-Web's
675
- base View forces `position: relative; z-index: 0`), so without the
676
- inline overrides the header renders in normal flow below the grid
677
- instead of floating over it. Inline wins; native is unchanged. */}
678
- <View
679
- style={{ position: 'absolute', top: 0, left: 0, right: 0, zIndex: 30 }}
680
- className="absolute top-0 left-0 right-0 flex-row items-center justify-between px-space-12 z-30 bg-[#000000EB] pt-[28px] min-h-[84px]"
681
- >
580
+ {/* Translucent black header floating over the grid. The Dialog owns
581
+ the drag handle (a floating pill above this bar in bottom
582
+ placement, drawn at a higher z-index; none in center), so the
583
+ header no longer reserves the 28dp handle strip it is just the
584
+ 56dp app bar (`min-h-[56px]`). */}
585
+ <View className="absolute top-0 left-0 right-0 flex-row items-center justify-between px-space-12 z-30 bg-[#000000EB] min-h-[56px]">
682
586
  <View className="flex-row items-center justify-between w-full h-14">
683
587
  <View className="basis-0 grow flex-row items-center justify-start">
684
588
  <TouchableOpacity