@oxyhq/bloom 0.69.1 → 0.71.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 (56) 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 +82 -5
  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 +83 -6
  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/select/index.web.d.ts +1 -1
  31. package/lib/typescript/commonjs/select/index.web.d.ts.map +1 -1
  32. package/lib/typescript/commonjs/select/types.d.ts +6 -0
  33. package/lib/typescript/commonjs/select/types.d.ts.map +1 -1
  34. package/lib/typescript/commonjs/zoomable-image-gallery/ZoomableImageGallery.d.ts.map +1 -1
  35. package/lib/typescript/module/context-menu/index.web.d.ts.map +1 -1
  36. package/lib/typescript/module/dialog/SheetShell.d.ts.map +1 -1
  37. package/lib/typescript/module/menu/index.web.d.ts.map +1 -1
  38. package/lib/typescript/module/overlay/dropdown-placement.d.ts +55 -0
  39. package/lib/typescript/module/overlay/dropdown-placement.d.ts.map +1 -0
  40. package/lib/typescript/module/select/index.web.d.ts +1 -1
  41. package/lib/typescript/module/select/index.web.d.ts.map +1 -1
  42. package/lib/typescript/module/select/types.d.ts +6 -0
  43. package/lib/typescript/module/select/types.d.ts.map +1 -1
  44. package/lib/typescript/module/zoomable-image-gallery/ZoomableImageGallery.d.ts.map +1 -1
  45. package/package.json +1 -1
  46. package/src/__tests__/SelectItemLabel.test.tsx +46 -0
  47. package/src/__tests__/SheetShell.test.tsx +82 -0
  48. package/src/__tests__/dropdown-placement.test.ts +90 -0
  49. package/src/__tests__/optional-peer-imports.test.ts +31 -12
  50. package/src/context-menu/index.web.tsx +47 -3
  51. package/src/dialog/SheetShell.tsx +5 -0
  52. package/src/menu/index.web.tsx +45 -16
  53. package/src/overlay/dropdown-placement.ts +88 -0
  54. package/src/select/index.web.tsx +92 -5
  55. package/src/select/types.ts +6 -0
  56. package/src/zoomable-image-gallery/ZoomableImageGallery.tsx +29 -16
@@ -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
+ });
@@ -343,7 +343,7 @@ describe('optional peers are loaded through Metro’s optional-dependency form',
343
343
  expect(notOptional).toEqual([]);
344
344
  });
345
345
 
346
- it('declares every package it loads through a require boundary', () => {
346
+ it('declares every package it loads at runtime, by import OR require', () => {
347
347
  // The inverse direction, and the one that matters: every other assertion in
348
348
  // this file starts from `peerDependenciesMeta` and asks whether the code
349
349
  // matches it, so by construction none of them can see a package the manifest
@@ -351,26 +351,45 @@ describe('optional peers are loaded through Metro’s optional-dependency form',
351
351
  // for exactly that reason — loaded, degraded correctly, and invisible to a
352
352
  // consumer, who had no range to install against and no way to learn the
353
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.
354
361
  const declared = new Set([
355
362
  ...Object.keys(PKG.peerDependencies),
356
363
  ...Object.keys(PKG.dependencies ?? {}),
357
364
  ]);
358
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
+
359
373
  const undeclared = new Set<string>();
360
374
  for (const file of files) {
361
- for (const { specifier, line } of requireCalls(parse(file))) {
362
- if (specifier === null || specifier.startsWith('.')) continue;
363
- const pkg = specifier.startsWith('@')
364
- ? specifier.split('/').slice(0, 2).join('/')
365
- : (specifier.split('/')[0] as string);
366
- if (declared.has(pkg)) continue;
367
- if (VARIABLE_REQUIRE_ALLOWED.some((entry) => file.endsWith(entry.file))) continue;
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;
368
384
  undeclared.add(
369
- `${file.slice(SRC.length + 1)}:${line} requires '${pkg}', which appears in neither ` +
370
- 'dependencies nor peerDependencies — a consumer cannot know the integration exists ' +
371
- 'or which versions satisfy it',
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',
372
388
  );
373
- }
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');
374
393
  }
375
394
 
376
395
  expect([...undeclared].sort()).toEqual([]);
@@ -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
+ }
@@ -1,5 +1,14 @@
1
- import React, { createContext, useContext, useId, useMemo, useState } from 'react';
2
- import { Pressable, StyleSheet, View } from 'react-native';
1
+ import React, {
2
+ createContext,
3
+ useCallback,
4
+ useContext,
5
+ useId,
6
+ useLayoutEffect,
7
+ useMemo,
8
+ useRef,
9
+ useState,
10
+ } from 'react';
11
+ import { Pressable, StyleSheet, View, type ViewStyle } from 'react-native';
3
12
 
4
13
  import { useTheme } from '../theme/use-theme';
5
14
  import { Text } from '../typography';
@@ -7,6 +16,7 @@ import { Backdrop } from '../overlay';
7
16
  import { Portal } from '../portal/index.web';
8
17
  import { createOverlayZIndex } from '../styles/z-index';
9
18
  import { WEB_POSITION_FIXED } from '../styles/web-view-style';
19
+ import { resolveDropdownPlacement } from '../overlay/dropdown-placement';
10
20
  import { bloomShadowStyle } from '../design-tokens/shadows';
11
21
  import { RadioIndicator } from '../radio-indicator';
12
22
  import { useInteractionState } from '../hooks/useInteractionState';
@@ -30,6 +40,8 @@ import type {
30
40
  export { useSelectItemContext };
31
41
 
32
42
  const selectZIndex = createOverlayZIndex();
43
+ const VIEWPORT_GUTTER = 8;
44
+ const SELECT_OFFSET = 6;
33
45
 
34
46
  // ---------------------------------------------------------------------------
35
47
  // Context
@@ -39,6 +51,8 @@ type SelectContextValue = Pick<SelectProps, 'value' | 'onValueChange' | 'disable
39
51
  isOpen: boolean;
40
52
  open: () => void;
41
53
  close: () => void;
54
+ /** The trigger element, so `SelectContent` can position itself against it. */
55
+ triggerRef: React.RefObject<unknown>;
42
56
  };
43
57
 
44
58
  const SelectContext = createContext<SelectContextValue | null>(null);
@@ -58,6 +72,7 @@ function useSelectContext(): SelectContextValue {
58
72
 
59
73
  export function Select({ children, value, onValueChange, disabled }: SelectProps) {
60
74
  const [isOpen, setIsOpen] = useState(false);
75
+ const triggerRef = useRef<unknown>(null);
61
76
 
62
77
  const ctx = useMemo<SelectContextValue>(
63
78
  () => ({
@@ -67,6 +82,7 @@ export function Select({ children, value, onValueChange, disabled }: SelectProps
67
82
  isOpen,
68
83
  open: () => setIsOpen(true),
69
84
  close: () => setIsOpen(false),
85
+ triggerRef,
70
86
  }),
71
87
  [value, onValueChange, disabled, isOpen],
72
88
  );
@@ -103,6 +119,7 @@ export function SelectTrigger({ children, label }: SelectTriggerProps) {
103
119
  pressed: false,
104
120
  },
105
121
  props: {
122
+ ref: ctx.triggerRef,
106
123
  onPress: ctx.open,
107
124
  onFocus,
108
125
  onBlur,
@@ -113,6 +130,7 @@ export function SelectTrigger({ children, label }: SelectTriggerProps) {
113
130
 
114
131
  return (
115
132
  <Pressable
133
+ ref={ctx.triggerRef as React.Ref<View>}
116
134
  onPress={ctx.open}
117
135
  onFocus={onFocus}
118
136
  onBlur={onBlur}
@@ -184,6 +202,58 @@ export function SelectContent<T>({
184
202
  }: SelectContentProps<T>) {
185
203
  const ctx = useSelectContext();
186
204
  const theme = useTheme();
205
+ const [position, setPosition] = useState<ViewStyle | null>(null);
206
+ // The mounted dropdown node, as STATE rather than a bare ref: positioning has
207
+ // to measure it, and `Portal` renders null on its first pass (it resolves its
208
+ // host in its own layout effect), so the node lands one render after `isOpen`
209
+ // flips. An effect keyed only on `isOpen` would measure nothing.
210
+ const [dropdownNode, setDropdownNode] = useState<HTMLElement | null>(null);
211
+
212
+ const attachDropdown = useCallback((node: View | null) => {
213
+ setDropdownNode(node as unknown as HTMLElement | null);
214
+ }, []);
215
+
216
+ // Anchored to the trigger, left edges aligned, and never off-screen: sit below
217
+ // when it fits, flip above when it doesn't, clamp when neither. Before this the
218
+ // dropdown had no `top`/`left` at all, so it rendered at the `Portal` root's
219
+ // origin — the viewport's top-left corner — however far from its trigger.
220
+ useLayoutEffect(() => {
221
+ if (!ctx.isOpen || typeof window === 'undefined') return;
222
+
223
+ const triggerNode = ctx.triggerRef.current as HTMLElement | null;
224
+ if (!triggerNode?.getBoundingClientRect || !dropdownNode) return;
225
+
226
+ const updatePosition = () => {
227
+ const rect = triggerNode.getBoundingClientRect();
228
+ // Measured BEFORE `minWidth` is applied, so the final surface can only be
229
+ // wider than this — and a wider surface wraps less, so the measured height
230
+ // is an upper bound. Erring that way flips early in a tie, never late.
231
+ const surface = dropdownNode.getBoundingClientRect();
232
+ const width = Math.max(surface.width, rect.width);
233
+
234
+ setPosition({
235
+ position: WEB_POSITION_FIXED,
236
+ ...resolveDropdownPlacement({
237
+ anchor: rect,
238
+ size: { width, height: surface.height },
239
+ viewport: { width: window.innerWidth, height: window.innerHeight },
240
+ offset: SELECT_OFFSET,
241
+ gutter: VIEWPORT_GUTTER,
242
+ align: 'start',
243
+ }),
244
+ minWidth: width,
245
+ });
246
+ };
247
+
248
+ updatePosition();
249
+ window.addEventListener('resize', updatePosition);
250
+ window.addEventListener('scroll', updatePosition, true);
251
+
252
+ return () => {
253
+ window.removeEventListener('resize', updatePosition);
254
+ window.removeEventListener('scroll', updatePosition, true);
255
+ };
256
+ }, [ctx.isOpen, ctx.triggerRef, dropdownNode]);
187
257
 
188
258
  if (!ctx.isOpen) return null;
189
259
 
@@ -195,6 +265,7 @@ export function SelectContent<T>({
195
265
  accessibilityLabel="Close selection"
196
266
  />
197
267
  <View
268
+ ref={attachDropdown}
198
269
  accessibilityRole="list"
199
270
  accessibilityLabel={label}
200
271
  style={[
@@ -206,6 +277,7 @@ export function SelectContent<T>({
206
277
  borderColor: theme.colors.borderLight,
207
278
  ...bloomShadowStyle('m'),
208
279
  },
280
+ position,
209
281
  ]}
210
282
  >
211
283
  {items.map((item, index) => (
@@ -222,7 +294,7 @@ export function SelectContent<T>({
222
294
  // SelectItem
223
295
  // ---------------------------------------------------------------------------
224
296
 
225
- export function SelectItem({ ref, value, children, style }: SelectItemProps) {
297
+ export function SelectItem({ ref, value, label, children, style }: SelectItemProps) {
226
298
  const theme = useTheme();
227
299
  const ctx = useSelectContext();
228
300
  const {
@@ -243,6 +315,10 @@ export function SelectItem({ ref, value, children, style }: SelectItemProps) {
243
315
  <Pressable
244
316
  ref={ref}
245
317
  accessibilityRole="radio"
318
+ // The native fork has always applied this; web declared `label` and then
319
+ // dropped it, leaving every option a `role="radio"` with no accessible
320
+ // name for a screen reader to announce.
321
+ accessibilityLabel={label}
246
322
  accessibilityState={{ checked: isSelected }}
247
323
  onPress={() => {
248
324
  ctx.onValueChange?.(value);
@@ -344,7 +420,14 @@ const styles = StyleSheet.create({
344
420
  pointerEvents: 'auto',
345
421
  },
346
422
  dropdown: {
347
- position: 'absolute',
423
+ // Fixed from the outset, not only once positioned: the `Portal` root is a
424
+ // block container, so a static child would stretch to the full viewport
425
+ // width and the pre-position measurement would under-read the height (less
426
+ // wrapping at a width the surface never actually has). A fixed box
427
+ // shrink-wraps to the same width it ends up with.
428
+ position: WEB_POSITION_FIXED,
429
+ top: 0,
430
+ left: 0,
348
431
  zIndex: selectZIndex.surface,
349
432
  borderRadius: 8,
350
433
  borderWidth: 1,
@@ -358,7 +441,11 @@ const styles = StyleSheet.create({
358
441
  item: {
359
442
  position: 'relative',
360
443
  flexDirection: 'row',
361
- minHeight: 25,
444
+ // Matches the native sheet row (`index.tsx`) and the web `Menu` row. Bloom's
445
+ // web build is what renders on touch tablets, where this dropdown — not the
446
+ // sheet — is the select, so the row carries the same 44dp target on both
447
+ // platforms.
448
+ minHeight: 44,
362
449
  paddingLeft: 30,
363
450
  paddingRight: 8,
364
451
  alignItems: 'center',
@@ -23,6 +23,12 @@ export type TriggerChildProps = {
23
23
  pressed: boolean;
24
24
  };
25
25
  props: {
26
+ /**
27
+ * Attach this to the element the trigger actually renders. On web the
28
+ * dropdown is positioned against its measured rect; a trigger that drops
29
+ * it falls back to the viewport's top-left corner.
30
+ */
31
+ ref?: React.Ref<unknown>;
26
32
  onPress: () => void;
27
33
  onFocus: () => void;
28
34
  onBlur: () => void;