@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.
- package/lib/commonjs/context-menu/index.web.js +51 -3
- package/lib/commonjs/context-menu/index.web.js.map +1 -1
- package/lib/commonjs/dialog/SheetShell.js +7 -1
- package/lib/commonjs/dialog/SheetShell.js.map +1 -1
- package/lib/commonjs/menu/index.web.js +47 -10
- package/lib/commonjs/menu/index.web.js.map +1 -1
- package/lib/commonjs/overlay/dropdown-placement.js +62 -0
- package/lib/commonjs/overlay/dropdown-placement.js.map +1 -0
- package/lib/commonjs/select/index.web.js +5 -1
- package/lib/commonjs/select/index.web.js.map +1 -1
- package/lib/commonjs/zoomable-image-gallery/ZoomableImageGallery.js +32 -18
- package/lib/commonjs/zoomable-image-gallery/ZoomableImageGallery.js.map +1 -1
- package/lib/module/context-menu/index.web.js +52 -4
- package/lib/module/context-menu/index.web.js.map +1 -1
- package/lib/module/dialog/SheetShell.js +7 -1
- package/lib/module/dialog/SheetShell.js.map +1 -1
- package/lib/module/menu/index.web.js +47 -10
- package/lib/module/menu/index.web.js.map +1 -1
- package/lib/module/overlay/dropdown-placement.js +58 -0
- package/lib/module/overlay/dropdown-placement.js.map +1 -0
- package/lib/module/select/index.web.js +5 -1
- package/lib/module/select/index.web.js.map +1 -1
- package/lib/module/zoomable-image-gallery/ZoomableImageGallery.js +33 -19
- package/lib/module/zoomable-image-gallery/ZoomableImageGallery.js.map +1 -1
- package/lib/typescript/commonjs/context-menu/index.web.d.ts.map +1 -1
- package/lib/typescript/commonjs/dialog/SheetShell.d.ts.map +1 -1
- package/lib/typescript/commonjs/menu/index.web.d.ts.map +1 -1
- package/lib/typescript/commonjs/overlay/dropdown-placement.d.ts +55 -0
- package/lib/typescript/commonjs/overlay/dropdown-placement.d.ts.map +1 -0
- package/lib/typescript/commonjs/zoomable-image-gallery/ZoomableImageGallery.d.ts.map +1 -1
- package/lib/typescript/module/context-menu/index.web.d.ts.map +1 -1
- package/lib/typescript/module/dialog/SheetShell.d.ts.map +1 -1
- package/lib/typescript/module/menu/index.web.d.ts.map +1 -1
- package/lib/typescript/module/overlay/dropdown-placement.d.ts +55 -0
- package/lib/typescript/module/overlay/dropdown-placement.d.ts.map +1 -0
- package/lib/typescript/module/zoomable-image-gallery/ZoomableImageGallery.d.ts.map +1 -1
- package/package.json +11 -7
- package/src/__tests__/SheetShell.test.tsx +82 -0
- package/src/__tests__/dropdown-placement.test.ts +90 -0
- package/src/__tests__/optional-peer-imports.test.ts +54 -0
- package/src/context-menu/index.web.tsx +47 -3
- package/src/dialog/SheetShell.tsx +5 -0
- package/src/menu/index.web.tsx +45 -16
- package/src/overlay/dropdown-placement.ts +88 -0
- package/src/select/index.web.tsx +5 -1
- 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
|
-
|
|
186
|
-
|
|
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
|
-
|
|
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()} />
|
package/src/menu/index.web.tsx
CHANGED
|
@@ -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
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
-
|
|
228
|
-
|
|
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={
|
|
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
|
-
|
|
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
|
+
}
|
package/src/select/index.web.tsx
CHANGED
|
@@ -358,7 +358,11 @@ const styles = StyleSheet.create({
|
|
|
358
358
|
item: {
|
|
359
359
|
position: 'relative',
|
|
360
360
|
flexDirection: 'row',
|
|
361
|
-
|
|
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
|
|
364
|
-
//
|
|
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
|
-
//
|
|
599
|
-
//
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
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:
|
|
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}
|