@oxyhq/bloom 0.69.0 → 0.70.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 (46) hide show
  1. package/lib/commonjs/context-menu/index.web.js +51 -3
  2. package/lib/commonjs/context-menu/index.web.js.map +1 -1
  3. package/lib/commonjs/dialog/SheetShell.js +7 -1
  4. package/lib/commonjs/dialog/SheetShell.js.map +1 -1
  5. package/lib/commonjs/menu/index.web.js +47 -10
  6. package/lib/commonjs/menu/index.web.js.map +1 -1
  7. package/lib/commonjs/overlay/dropdown-placement.js +62 -0
  8. package/lib/commonjs/overlay/dropdown-placement.js.map +1 -0
  9. package/lib/commonjs/select/index.web.js +5 -1
  10. package/lib/commonjs/select/index.web.js.map +1 -1
  11. package/lib/commonjs/zoomable-image-gallery/ZoomableImageGallery.js +32 -18
  12. package/lib/commonjs/zoomable-image-gallery/ZoomableImageGallery.js.map +1 -1
  13. package/lib/module/context-menu/index.web.js +52 -4
  14. package/lib/module/context-menu/index.web.js.map +1 -1
  15. package/lib/module/dialog/SheetShell.js +7 -1
  16. package/lib/module/dialog/SheetShell.js.map +1 -1
  17. package/lib/module/menu/index.web.js +47 -10
  18. package/lib/module/menu/index.web.js.map +1 -1
  19. package/lib/module/overlay/dropdown-placement.js +58 -0
  20. package/lib/module/overlay/dropdown-placement.js.map +1 -0
  21. package/lib/module/select/index.web.js +5 -1
  22. package/lib/module/select/index.web.js.map +1 -1
  23. package/lib/module/zoomable-image-gallery/ZoomableImageGallery.js +33 -19
  24. package/lib/module/zoomable-image-gallery/ZoomableImageGallery.js.map +1 -1
  25. package/lib/typescript/commonjs/context-menu/index.web.d.ts.map +1 -1
  26. package/lib/typescript/commonjs/dialog/SheetShell.d.ts.map +1 -1
  27. package/lib/typescript/commonjs/menu/index.web.d.ts.map +1 -1
  28. package/lib/typescript/commonjs/overlay/dropdown-placement.d.ts +55 -0
  29. package/lib/typescript/commonjs/overlay/dropdown-placement.d.ts.map +1 -0
  30. package/lib/typescript/commonjs/zoomable-image-gallery/ZoomableImageGallery.d.ts.map +1 -1
  31. package/lib/typescript/module/context-menu/index.web.d.ts.map +1 -1
  32. package/lib/typescript/module/dialog/SheetShell.d.ts.map +1 -1
  33. package/lib/typescript/module/menu/index.web.d.ts.map +1 -1
  34. package/lib/typescript/module/overlay/dropdown-placement.d.ts +55 -0
  35. package/lib/typescript/module/overlay/dropdown-placement.d.ts.map +1 -0
  36. package/lib/typescript/module/zoomable-image-gallery/ZoomableImageGallery.d.ts.map +1 -1
  37. package/package.json +11 -7
  38. package/src/__tests__/SheetShell.test.tsx +82 -0
  39. package/src/__tests__/dropdown-placement.test.ts +90 -0
  40. package/src/__tests__/optional-peer-imports.test.ts +54 -0
  41. package/src/context-menu/index.web.tsx +47 -3
  42. package/src/dialog/SheetShell.tsx +5 -0
  43. package/src/menu/index.web.tsx +45 -16
  44. package/src/overlay/dropdown-placement.ts +88 -0
  45. package/src/select/index.web.tsx +5 -1
  46. package/src/zoomable-image-gallery/ZoomableImageGallery.tsx +29 -16
@@ -0,0 +1,82 @@
1
+ import React from 'react';
2
+ import { Text } from 'react-native';
3
+ import { act, render } from '@testing-library/react-native';
4
+ import type { ReactTestInstance } from 'react-test-renderer';
5
+
6
+ import BottomSheet, { type BottomSheetRef } from '../bottom-sheet';
7
+ import { useDialogControl } from '../dialog/context';
8
+ import { SheetShell } from '../dialog/SheetShell';
9
+ import { Z_INDEX } from '../styles/z-index';
10
+ import { BloomThemeProvider } from '../theme/BloomThemeProvider';
11
+
12
+ /**
13
+ * `BottomSheet` draws a decorative drag handle by default; `SheetShell` draws
14
+ * its own PRESSABLE one (tap to dismiss). Leaving both on painted two pills
15
+ * 2dp apart in every Bloom sheet menu, select, popover and context menu, so
16
+ * `SheetShell` turns the built-in one off.
17
+ *
18
+ * The built-in handle is identified by the z-index reserved for it, which no
19
+ * other element in the tree uses. `describe('control case')` below proves that
20
+ * query can actually FIND a handle — without it, a query that silently matched
21
+ * nothing would report the fix as working whether or not it did.
22
+ */
23
+ const sheetHandles = (root: ReactTestInstance) =>
24
+ root.findAll((node) => {
25
+ const style: unknown = node.props?.style;
26
+ return (
27
+ typeof style === 'object' &&
28
+ style !== null &&
29
+ !Array.isArray(style) &&
30
+ (style as { zIndex?: number }).zIndex === Z_INDEX.sheetHandle
31
+ );
32
+ });
33
+
34
+ function renderWithTheme(ui: React.ReactElement) {
35
+ return render(
36
+ <BloomThemeProvider mode="light" colorPreset="teal">
37
+ {ui}
38
+ </BloomThemeProvider>,
39
+ );
40
+ }
41
+
42
+ function OpenSheetShell() {
43
+ const control = useDialogControl();
44
+ React.useEffect(() => {
45
+ control.open();
46
+ }, [control]);
47
+ return (
48
+ <SheetShell control={control} label="Menu">
49
+ <Text>Row</Text>
50
+ </SheetShell>
51
+ );
52
+ }
53
+
54
+ describe('SheetShell drag handle', () => {
55
+ it('renders exactly one handle — its own pressable one', () => {
56
+ const { UNSAFE_root, getByHintText, getByText } = renderWithTheme(<OpenSheetShell />);
57
+ act(() => {});
58
+
59
+ expect(getByText('Row')).toBeTruthy();
60
+ // SheetShell's own handle: a Pressable that dismisses the sheet on tap.
61
+ // Matched on the hint rather than the label, which the backdrop shares.
62
+ expect(getByHintText('Tap to close')).toBeTruthy();
63
+ // BottomSheet's built-in decorative handle must be suppressed.
64
+ expect(sheetHandles(UNSAFE_root)).toHaveLength(0);
65
+ });
66
+
67
+ describe('control case', () => {
68
+ it('finds the built-in handle on a plain BottomSheet, which leaves it on', () => {
69
+ const ref = React.createRef<BottomSheetRef>();
70
+ const { UNSAFE_root } = renderWithTheme(
71
+ <BottomSheet ref={ref}>
72
+ <Text>Row</Text>
73
+ </BottomSheet>,
74
+ );
75
+ act(() => {
76
+ ref.current?.present();
77
+ });
78
+
79
+ expect(sheetHandles(UNSAFE_root).length).toBeGreaterThan(0);
80
+ });
81
+ });
82
+ });
@@ -0,0 +1,90 @@
1
+ import {
2
+ resolveDropdownPlacement,
3
+ type DropdownPlacementInput,
4
+ } from '../overlay/dropdown-placement';
5
+
6
+ const VIEWPORT = { width: 1000, height: 500 };
7
+ const GUTTER = 8;
8
+
9
+ /** A 200x40 trigger at (400, y), the shape the web `Menu` anchors against. */
10
+ function trigger(y: number) {
11
+ return { top: y, bottom: y + 40, left: 400, right: 600 };
12
+ }
13
+
14
+ function place(overrides: Partial<DropdownPlacementInput>): { top: number; left: number } {
15
+ return resolveDropdownPlacement({
16
+ anchor: trigger(100),
17
+ size: { width: 180, height: 100 },
18
+ viewport: VIEWPORT,
19
+ offset: 6,
20
+ gutter: GUTTER,
21
+ align: 'end',
22
+ ...overrides,
23
+ });
24
+ }
25
+
26
+ describe('resolveDropdownPlacement', () => {
27
+ describe('vertical', () => {
28
+ it('sits below the anchor when it fits', () => {
29
+ // Trigger 100..140, surface 100 tall, offset 6 → 146..246, well inside 500.
30
+ expect(place({}).top).toBe(146);
31
+ });
32
+
33
+ it('flips above the anchor when there is no room below', () => {
34
+ // Trigger 420..460: below would end at 566, past 500 - 8. Above starts at
35
+ // 420 - 6 - 100 = 314, which clears the top gutter.
36
+ expect(place({ anchor: trigger(420) }).top).toBe(314);
37
+ });
38
+
39
+ it('prefers below when BOTH sides fit — flipping is a fallback, not a preference', () => {
40
+ expect(place({ anchor: trigger(200), size: { width: 180, height: 100 } }).top).toBe(246);
41
+ });
42
+
43
+ it('clamps into the viewport when neither side fits', () => {
44
+ // 460 tall against a 500 viewport: nothing fits either side of a
45
+ // mid-screen trigger, so it pins to 500 - 8 - 460 = 32.
46
+ expect(place({ anchor: trigger(200), size: { width: 180, height: 460 } }).top).toBe(32);
47
+ });
48
+
49
+ it('pins to the top gutter when the surface is taller than the viewport', () => {
50
+ // Overflowing the BOTTOM keeps the first rows reachable; the opposite
51
+ // choice would push the surface's start off-screen and strand every row.
52
+ expect(place({ anchor: trigger(200), size: { width: 180, height: 900 } }).top).toBe(GUTTER);
53
+ });
54
+
55
+ it('treats a zero-area anchor (a right-click point) as its own edge', () => {
56
+ const point = { top: 300, bottom: 300, left: 900, right: 900 };
57
+ expect(place({ anchor: point, offset: 0, align: 'start' }).top).toBe(300);
58
+ // 300 + 250 = 550 > 500 - 8, so it flips to 300 - 250 = 50.
59
+ expect(
60
+ place({ anchor: point, offset: 0, align: 'start', size: { width: 180, height: 250 } }).top,
61
+ ).toBe(50);
62
+ });
63
+ });
64
+
65
+ describe('horizontal', () => {
66
+ it("align 'end' lines the surface's right edge up with the anchor's", () => {
67
+ expect(place({ size: { width: 180, height: 100 } }).left).toBe(420);
68
+ });
69
+
70
+ it("align 'start' lines the surface's left edge up with the anchor's", () => {
71
+ expect(place({ align: 'start' }).left).toBe(400);
72
+ });
73
+
74
+ it('clamps a surface that would overflow the right edge', () => {
75
+ const point = { top: 100, bottom: 100, left: 980, right: 980 };
76
+ // 980 + 180 would reach 1160; pinned to 1000 - 8 - 180.
77
+ expect(place({ anchor: point, align: 'start' }).left).toBe(812);
78
+ });
79
+
80
+ it('clamps a surface that would overflow the left edge', () => {
81
+ const narrow = { top: 100, bottom: 140, left: 0, right: 20 };
82
+ // Right-aligning to x=20 would start at -160.
83
+ expect(place({ anchor: narrow }).left).toBe(GUTTER);
84
+ });
85
+
86
+ it('pins to the left gutter when the surface is wider than the viewport', () => {
87
+ expect(place({ size: { width: 1200, height: 100 } }).left).toBe(GUTTER);
88
+ });
89
+ });
90
+ });
@@ -50,6 +50,7 @@ import ts from 'typescript';
50
50
 
51
51
  const SRC = join(__dirname, '..');
52
52
  const PKG = JSON.parse(readFileSync(join(SRC, '..', 'package.json'), 'utf8')) as {
53
+ dependencies?: Record<string, string>;
53
54
  peerDependencies: Record<string, string>;
54
55
  peerDependenciesMeta: Record<string, { optional?: boolean }>;
55
56
  };
@@ -94,6 +95,7 @@ const DYNAMIC_BOUNDARIES: { peer: string; file: string }[] = [
94
95
  { peer: 'expo-haptics', file: 'hooks/haptics-module.ts' },
95
96
  { peer: 'nativewind', file: 'theme/color-scope/style-builder.ts' },
96
97
  { peer: 'expo-router', file: 'theme/adaptive-colors.ts' },
98
+ { peer: 'react-native-keyboard-controller', file: 'bottom-sheet/index.tsx' },
97
99
  ];
98
100
 
99
101
  /**
@@ -341,6 +343,58 @@ describe('optional peers are loaded through Metro’s optional-dependency form',
341
343
  expect(notOptional).toEqual([]);
342
344
  });
343
345
 
346
+ it('declares every package it loads at runtime, by import OR require', () => {
347
+ // The inverse direction, and the one that matters: every other assertion in
348
+ // this file starts from `peerDependenciesMeta` and asks whether the code
349
+ // matches it, so by construction none of them can see a package the manifest
350
+ // never mentions. `react-native-keyboard-controller` sat in `bottom-sheet`
351
+ // for exactly that reason — loaded, degraded correctly, and invisible to a
352
+ // consumer, who had no range to install against and no way to learn the
353
+ // integration existed.
354
+ //
355
+ // BOTH vectors are scanned. Checking only `require` would leave the more
356
+ // common one open: a plain `import` of an undeclared package is the ordinary
357
+ // way one arrives, it resolves fine as long as some transitive dependency
358
+ // happens to hoist it into `node_modules`, and it disappears the moment that
359
+ // unrelated package drops it. Verified by mutation in both directions — a
360
+ // require-only scan passes an undeclared static import.
361
+ const declared = new Set([
362
+ ...Object.keys(PKG.peerDependencies),
363
+ ...Object.keys(PKG.dependencies ?? {}),
364
+ ]);
365
+
366
+ /** `react-dom/client` -> `react-dom`, `@scope/pkg/sub` -> `@scope/pkg`. */
367
+ function packageOf(specifier: string): string | null {
368
+ if (specifier.startsWith('.') || specifier.startsWith('/')) return null;
369
+ const parts = specifier.split('/');
370
+ return specifier.startsWith('@') ? parts.slice(0, 2).join('/') : (parts[0] as string);
371
+ }
372
+
373
+ const undeclared = new Set<string>();
374
+ for (const file of files) {
375
+ const source = parse(file);
376
+ const relative = file.slice(SRC.length + 1);
377
+
378
+ const check = (specifier: string | null, line: number, verb: string): void => {
379
+ // A non-literal specifier names no package to check; the shape rule for
380
+ // those is the separate `VARIABLE_REQUIRE_ALLOWED` scan below.
381
+ if (specifier === null) return;
382
+ const pkg = packageOf(specifier);
383
+ if (pkg === null || declared.has(pkg)) return;
384
+ undeclared.add(
385
+ `${relative}:${line} ${verb} '${pkg}', which appears in neither dependencies nor ` +
386
+ 'peerDependencies — a consumer cannot know the integration exists or which ' +
387
+ 'versions satisfy it',
388
+ );
389
+ };
390
+
391
+ for (const { specifier, line } of requireCalls(source)) check(specifier, line, 'requires');
392
+ for (const { specifier, line } of valueImportSpecifiers(source)) check(specifier, line, 'imports');
393
+ }
394
+
395
+ expect([...undeclared].sort()).toEqual([]);
396
+ });
397
+
344
398
  it('loads each optional peer from exactly the documented boundary file', () => {
345
399
  // Both directions in one assertion: a boundary that moved (or was deleted)
346
400
  // leaves a stale table entry; a second load site for the same peer, or a new
@@ -9,6 +9,7 @@ import React, {
9
9
  useCallback,
10
10
  useContext,
11
11
  useEffect,
12
+ useLayoutEffect,
12
13
  useMemo,
13
14
  useRef,
14
15
  useState,
@@ -22,6 +23,10 @@ import { Portal } from '../portal/index.web';
22
23
  import { useInteractionState } from '../hooks/useInteractionState';
23
24
  import { createOverlayZIndex } from '../styles/z-index';
24
25
  import { WEB_POSITION_FIXED } from '../styles/web-view-style';
26
+ import {
27
+ resolveDropdownPlacement,
28
+ type DropdownPlacement,
29
+ } from '../overlay/dropdown-placement';
25
30
  import { bloomShadowStyle } from '../design-tokens/shadows';
26
31
  import { ItemCtx, useItemContext } from './context';
27
32
  import type {
@@ -36,6 +41,7 @@ import type {
36
41
  } from './types';
37
42
 
38
43
  const contextMenuZIndex = createOverlayZIndex();
44
+ const VIEWPORT_GUTTER = 8;
39
45
 
40
46
  // ---------------------------------------------------------------------------
41
47
  // Web-specific context (extends base with position)
@@ -156,6 +162,38 @@ export function ContextMenuContent({ children, style }: ContextMenuContentProps)
156
162
  const ctx = useWebContextMenuContext();
157
163
  const theme = useTheme();
158
164
  const { isOpen, close, position } = ctx;
165
+ // The mounted menu node, as STATE rather than a bare ref: placement has to
166
+ // measure it, and `Portal` renders null on its first pass (it resolves its
167
+ // host in its own layout effect), so the node lands one render after the
168
+ // menu opens. An effect keyed only on `position` would measure nothing.
169
+ const [menuNode, setMenuNode] = useState<HTMLElement | null>(null);
170
+ const [placement, setPlacement] = useState<DropdownPlacement | null>(null);
171
+
172
+ const attachMenu = useCallback((node: View | null) => {
173
+ setMenuNode(node as unknown as HTMLElement | null);
174
+ }, []);
175
+
176
+ // The right-click point is a zero-area anchor, so the surface sits below-right
177
+ // of the cursor when it fits, flips above when it doesn't, and clamps into the
178
+ // viewport when neither fits. Without this a menu opened near an edge hung off
179
+ // the fold with its rows unreachable.
180
+ useLayoutEffect(() => {
181
+ if (!position || !menuNode || typeof window === 'undefined') {
182
+ setPlacement(null);
183
+ return;
184
+ }
185
+ const surface = menuNode.getBoundingClientRect();
186
+ setPlacement(
187
+ resolveDropdownPlacement({
188
+ anchor: { top: position.y, bottom: position.y, left: position.x, right: position.x },
189
+ size: { width: surface.width, height: surface.height },
190
+ viewport: { width: window.innerWidth, height: window.innerHeight },
191
+ offset: 0,
192
+ gutter: VIEWPORT_GUTTER,
193
+ align: 'start',
194
+ }),
195
+ );
196
+ }, [position, menuNode]);
159
197
 
160
198
  useEffect(() => {
161
199
  if (!isOpen || typeof document === 'undefined') return;
@@ -179,11 +217,14 @@ export function ContextMenuContent({ children, style }: ContextMenuContentProps)
179
217
  accessibilityLabel="Close context menu"
180
218
  />
181
219
  <View
220
+ ref={attachMenu}
182
221
  style={[
183
222
  styles.dropdown,
184
223
  {
185
- top: position.y,
186
- left: position.x,
224
+ // The raw click point is the pre-measurement anchor; the layout
225
+ // effect above replaces it before the browser paints.
226
+ top: placement?.top ?? position.y,
227
+ left: placement?.left ?? position.x,
187
228
  backgroundColor: theme.isDark
188
229
  ? theme.colors.backgroundSecondary
189
230
  : theme.colors.background,
@@ -358,7 +399,10 @@ const styles = StyleSheet.create({
358
399
  borderRadius: 6,
359
400
  overflow: 'hidden',
360
401
  paddingHorizontal: 10,
361
- minHeight: 32,
402
+ // Matches the native sheet row (`index.tsx`). Bloom's web build is what
403
+ // renders on touch tablets, where this dropdown — not the sheet — is the
404
+ // menu, so the row carries the same 44dp target on both platforms.
405
+ minHeight: 44,
362
406
  },
363
407
  itemText: {
364
408
  flex: 1,
@@ -100,6 +100,11 @@ export function SheetShell({
100
100
  detached
101
101
  backdropOpacity={0.7}
102
102
  style={sheetStyle}
103
+ // `SheetShell` draws its own PRESSABLE handle below (tap to dismiss);
104
+ // `BottomSheet`'s built-in one is decorative and defaults to on, so
105
+ // leaving it enabled painted two pills 2dp apart in every Bloom sheet
106
+ // menu, select, popover and context menu.
107
+ showHandle={false}
103
108
  >
104
109
  <Context.Provider value={context}>
105
110
  <SheetHandle onPress={() => close()} />
@@ -18,6 +18,7 @@ import type { DialogControlProps } from '../dialog/types';
18
18
  import { Portal } from '../portal/index.web';
19
19
  import { createDropdownZIndex } from '../styles/z-index';
20
20
  import { WEB_POSITION_FIXED } from '../styles/web-view-style';
21
+ import { resolveDropdownPlacement } from '../overlay/dropdown-placement';
21
22
  import { bloomShadowStyle } from '../design-tokens/shadows';
22
23
  import {
23
24
  MenuContext,
@@ -198,6 +199,22 @@ export function MenuContent({
198
199
  const theme = useTheme();
199
200
  const context = useMenuContext();
200
201
  const [position, setPosition] = useState<ViewStyle | null>(null);
202
+ // The mounted dropdown node, as STATE rather than a bare ref: positioning has
203
+ // to measure it, and `Portal` renders null on its first pass (it resolves its
204
+ // host in its own layout effect), so the node lands one render after
205
+ // `isOpen` flips. An effect keyed only on `isOpen` would measure nothing.
206
+ const [dropdownNode, setDropdownNode] = useState<HTMLElement | null>(null);
207
+
208
+ const attachDropdown = useCallback(
209
+ (node: View | null) => {
210
+ // The shared ref stays in sync — `Menu`'s outside-pointerdown check reads it.
211
+ if (context.dropdownRef) {
212
+ context.dropdownRef.current = node;
213
+ }
214
+ setDropdownNode(node as unknown as HTMLElement | null);
215
+ },
216
+ [context.dropdownRef],
217
+ );
201
218
 
202
219
  useLayoutEffect(() => {
203
220
  if (!context.isOpen || typeof window === 'undefined') {
@@ -205,27 +222,28 @@ export function MenuContent({
205
222
  }
206
223
 
207
224
  const triggerNode = context.triggerRef?.current as HTMLElement | null;
208
- if (!triggerNode?.getBoundingClientRect) {
225
+ if (!triggerNode?.getBoundingClientRect || !dropdownNode) {
209
226
  return;
210
227
  }
211
228
 
212
229
  const updatePosition = () => {
213
230
  const rect = triggerNode.getBoundingClientRect();
214
- const width = Math.max(180, rect.width);
215
- const availableRight = window.innerWidth - VIEWPORT_GUTTER;
216
- const left = Math.min(
217
- Math.max(VIEWPORT_GUTTER, rect.right - width),
218
- Math.max(VIEWPORT_GUTTER, availableRight - width),
219
- );
220
- const top = Math.min(
221
- rect.bottom + MENU_OFFSET,
222
- Math.max(VIEWPORT_GUTTER, window.innerHeight - VIEWPORT_GUTTER),
223
- );
231
+ // Measured BEFORE `minWidth` is applied, so the final surface can only be
232
+ // wider than this and a wider surface wraps less, so the measured height
233
+ // is an upper bound. Erring that way flips early in a tie, never late.
234
+ const surface = dropdownNode.getBoundingClientRect();
235
+ const width = Math.max(surface.width, rect.width);
224
236
 
225
237
  setPosition({
226
238
  position: WEB_POSITION_FIXED,
227
- top,
228
- left,
239
+ ...resolveDropdownPlacement({
240
+ anchor: rect,
241
+ size: { width, height: surface.height },
242
+ viewport: { width: window.innerWidth, height: window.innerHeight },
243
+ offset: MENU_OFFSET,
244
+ gutter: VIEWPORT_GUTTER,
245
+ align: 'end',
246
+ }),
229
247
  right: undefined,
230
248
  bottom: undefined,
231
249
  minWidth: width,
@@ -240,7 +258,7 @@ export function MenuContent({
240
258
  window.removeEventListener('resize', updatePosition);
241
259
  window.removeEventListener('scroll', updatePosition, true);
242
260
  };
243
- }, [context.isOpen, context.triggerRef]);
261
+ }, [context.isOpen, context.triggerRef, dropdownNode]);
244
262
 
245
263
  if (!context.isOpen) {
246
264
  return null;
@@ -249,7 +267,7 @@ export function MenuContent({
249
267
  return (
250
268
  <Portal>
251
269
  <View
252
- ref={context.dropdownRef as React.Ref<View>}
270
+ ref={attachDropdown}
253
271
  style={[
254
272
  styles.dropdown,
255
273
  {
@@ -390,6 +408,14 @@ const styles = StyleSheet.create({
390
408
  zIndex: menuZIndex.root,
391
409
  },
392
410
  dropdown: {
411
+ // Fixed from the outset, not only once positioned: the `Portal` root is a
412
+ // block container, so a static child would stretch to the full viewport
413
+ // width and the pre-position measurement would under-read the height (less
414
+ // wrapping at a width the surface never actually has). A fixed box
415
+ // shrink-wraps to the same width it ends up with.
416
+ position: WEB_POSITION_FIXED,
417
+ top: 0,
418
+ left: 0,
393
419
  borderRadius: 8,
394
420
  padding: 4,
395
421
  borderWidth: 1,
@@ -409,7 +435,10 @@ const styles = StyleSheet.create({
409
435
  borderRadius: 6,
410
436
  overflow: 'hidden',
411
437
  paddingHorizontal: 10,
412
- minHeight: 32,
438
+ // Matches the native sheet row (`index.tsx`). Bloom's web build is what
439
+ // renders on touch tablets, where this dropdown — not the sheet — is the
440
+ // menu, so the row carries the same 44dp target on both platforms.
441
+ minHeight: 44,
413
442
  },
414
443
  webItemText: {
415
444
  flex: 1,
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Placement arithmetic for a portaled dropdown surface — shared by the web
3
+ * `Menu` and `ContextMenu` forks, which anchor differently (a trigger's rect
4
+ * vs. a right-click point) but need the same fit / flip / clamp decision.
5
+ *
6
+ * Internal: deliberately NOT re-exported from `overlay/index.ts`. The web forks
7
+ * import it directly, the same way `dialog/SheetShell` is shared without
8
+ * becoming public API.
9
+ *
10
+ * Kept as a pure function so the arithmetic is unit-testable on its own — the
11
+ * DOM half (measuring the surface before positioning it) is the call site's
12
+ * job and is verified in a browser.
13
+ */
14
+
15
+ /** Viewport-relative box the surface is positioned against. A right-click point is a zero-area anchor. */
16
+ export interface DropdownAnchor {
17
+ top: number;
18
+ bottom: number;
19
+ left: number;
20
+ right: number;
21
+ }
22
+
23
+ export interface DropdownPlacementInput {
24
+ anchor: DropdownAnchor;
25
+ /** The surface's size, measured at the width it will finally be laid out at. */
26
+ size: { width: number; height: number };
27
+ viewport: { width: number; height: number };
28
+ /** Gap left between the anchor and the surface on the vertical axis. */
29
+ offset: number;
30
+ /** Minimum distance kept from every viewport edge. */
31
+ gutter: number;
32
+ /**
33
+ * Which of the surface's horizontal edges lines up with the anchor's matching
34
+ * edge: `'start'` pins left-to-left (a menu opening rightward from a click),
35
+ * `'end'` pins right-to-right (a trigger-anchored dropdown).
36
+ */
37
+ align: 'start' | 'end';
38
+ }
39
+
40
+ export interface DropdownPlacement {
41
+ top: number;
42
+ left: number;
43
+ }
44
+
45
+ /**
46
+ * Clamp into `[min, max]`, resolving an inverted range to `min`.
47
+ *
48
+ * The range inverts exactly when the surface is larger than the viewport minus
49
+ * its gutters. Preferring `min` then pins the surface to the top/left gutter and
50
+ * lets it overflow the far edge, so its FIRST rows stay reachable — the opposite
51
+ * choice would push its start off-screen and strand every row.
52
+ */
53
+ function clamp(value: number, min: number, max: number): number {
54
+ return Math.max(min, Math.min(value, max));
55
+ }
56
+
57
+ /**
58
+ * Resolve the viewport-relative `top`/`left` for a dropdown surface.
59
+ *
60
+ * Vertical: sit below the anchor when it fits, else flip above it, else clamp
61
+ * into the viewport. Horizontal: align per `align`, then clamp into the
62
+ * viewport.
63
+ */
64
+ export function resolveDropdownPlacement({
65
+ anchor,
66
+ size,
67
+ viewport,
68
+ offset,
69
+ gutter,
70
+ align,
71
+ }: DropdownPlacementInput): DropdownPlacement {
72
+ const below = anchor.bottom + offset;
73
+ const above = anchor.top - offset - size.height;
74
+
75
+ const fitsBelow = below + size.height <= viewport.height - gutter;
76
+ const fitsAbove = above >= gutter;
77
+
78
+ const top = fitsBelow
79
+ ? below
80
+ : fitsAbove
81
+ ? above
82
+ : clamp(below, gutter, viewport.height - gutter - size.height);
83
+
84
+ const preferredLeft = align === 'end' ? anchor.right - size.width : anchor.left;
85
+ const left = clamp(preferredLeft, gutter, viewport.width - gutter - size.width);
86
+
87
+ return { top, left };
88
+ }
@@ -358,7 +358,11 @@ const styles = StyleSheet.create({
358
358
  item: {
359
359
  position: 'relative',
360
360
  flexDirection: 'row',
361
- minHeight: 25,
361
+ // Matches the native sheet row (`index.tsx`) and the web `Menu` row. Bloom's
362
+ // web build is what renders on touch tablets, where this dropdown — not the
363
+ // sheet — is the select, so the row carries the same 44dp target on both
364
+ // platforms.
365
+ minHeight: 44,
362
366
  paddingLeft: 30,
363
367
  paddingRight: 8,
364
368
  alignItems: 'center',
@@ -1,4 +1,4 @@
1
- import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
1
+ import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
2
2
  import {
3
3
  View,
4
4
  Text,
@@ -180,8 +180,6 @@ const ZoomableImageGalleryInner = React.forwardRef<ZoomableImageGalleryHandle, Z
180
180
  const originScale = useSharedValue(1);
181
181
 
182
182
  const pagerRef = useRef<ScrollView>(null);
183
- // Latch the index the pager must land on once it has mounted + laid out.
184
- const pendingIndexRef = useRef(0);
185
183
  // Mirror of `activeIndex` readable synchronously from callbacks/worklets
186
184
  // (`handleDismiss` runs via `runOnJS` and from `Pressable.onPress`, where the
187
185
  // state closure can be stale). Kept in lockstep by `setActiveIndexBoth`.
@@ -360,8 +358,8 @@ const ZoomableImageGalleryInner = React.forwardRef<ZoomableImageGalleryHandle, Z
360
358
  ]);
361
359
 
362
360
  // Reveal the swipeable pager once the open animation has settled. The index it
363
- // lands on is held in `pendingIndexRef` (set synchronously in `open`) and
364
- // applied in `onPagerLayout`.
361
+ // lands on is `activeIndexRef` (set synchronously in `open`), seated by the
362
+ // layout effect below.
365
363
  const revealPager = useCallback(() => {
366
364
  setPagerReady(true);
367
365
  }, []);
@@ -388,7 +386,6 @@ const ZoomableImageGalleryInner = React.forwardRef<ZoomableImageGalleryHandle, Z
388
386
  if (r !== undefined) initialRatios[i] = r;
389
387
  });
390
388
  setPageRatios(initialRatios);
391
- pendingIndexRef.current = safeIndex;
392
389
 
393
390
  // Resolve the opening ratio if it was not yet known, then re-fit.
394
391
  if (knownRatio === undefined) {
@@ -595,14 +592,31 @@ const ZoomableImageGalleryInner = React.forwardRef<ZoomableImageGalleryHandle, Z
595
592
  [updateIndexFromOffset]
596
593
  );
597
594
 
598
- // When the pager mounts, jump it to the open index without animation so the
599
- // swap from the open-image to the pager is seamless.
600
- const onPagerLayout = useCallback(() => {
601
- const idx = pendingIndexRef.current;
602
- if (idx > 0) {
603
- pagerRef.current?.scrollTo({ x: idx * SCREEN_WIDTH, animated: false });
604
- }
605
- }, [SCREEN_WIDTH]);
595
+ // Re-seat the pager on the CURRENT page, without animation, whenever it mounts
596
+ // or the page width changes.
597
+ //
598
+ // Rotation is the case that needs this: the pager's retained scroll offset is
599
+ // in PIXELS, so when `SCREEN_WIDTH` changes the page widths change underneath
600
+ // a stale offset and the pager lands between two pages — showing the wrong
601
+ // image while the counter and dots still point at the right one. Nothing else
602
+ // corrects it: `contentOffset` below is only an initial value, and `pageTo`
603
+ // runs only on an explicit page change. Reading the index from
604
+ // `activeIndexRef` (rather than the index the viewer was opened at) is what
605
+ // makes it land on the page the user is actually looking at.
606
+ //
607
+ // This is a layout EFFECT rather than the pager's `onLayout` on purpose. The
608
+ // pages are sized from `SCREEN_WIDTH`, so the scroll offset is only meaningful
609
+ // once React has committed a render carrying the new width. `onLayout` fires
610
+ // from a different signal (the pager's own frame) than the one that resizes
611
+ // the pages (`useWindowDimensions`), and the two are not ordered against each
612
+ // other — so an `onLayout` handler can run holding the previous `SCREEN_WIDTH`
613
+ // and re-seat the pager to the offset it already had. Keying the effect on
614
+ // `SCREEN_WIDTH` makes the two agree by construction, and running it before
615
+ // paint keeps the corrected offset from being visible as a jump.
616
+ useLayoutEffect(() => {
617
+ if (!pagerReady) return;
618
+ pagerRef.current?.scrollTo({ x: activeIndexRef.current * SCREEN_WIDTH, y: 0, animated: false });
619
+ }, [pagerReady, SCREEN_WIDTH]);
606
620
 
607
621
  // Double-tap toggles zoom: reset when already zoomed, else zoom to the tapped
608
622
  // point (biased toward it, clamped near the image center). `x`/`y` are local
@@ -834,8 +848,7 @@ const ZoomableImageGalleryInner = React.forwardRef<ZoomableImageGalleryHandle, Z
834
848
  // instead of paging away from it.
835
849
  scrollEnabled={!isZoomed}
836
850
  showsHorizontalScrollIndicator={false}
837
- contentOffset={{ x: pendingIndexRef.current * SCREEN_WIDTH, y: 0 }}
838
- onLayout={onPagerLayout}
851
+ contentOffset={{ x: activeIndexRef.current * SCREEN_WIDTH, y: 0 }}
839
852
  onMomentumScrollEnd={onPagerScroll}
840
853
  {...(Platform.OS === 'web' ? { onScroll: onPagerScroll } : {})}
841
854
  scrollEventThrottle={16}