@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.
- 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 +82 -5
- 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 +83 -6
- 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/select/index.web.d.ts +1 -1
- package/lib/typescript/commonjs/select/index.web.d.ts.map +1 -1
- package/lib/typescript/commonjs/select/types.d.ts +6 -0
- package/lib/typescript/commonjs/select/types.d.ts.map +1 -1
- 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/select/index.web.d.ts +1 -1
- package/lib/typescript/module/select/index.web.d.ts.map +1 -1
- package/lib/typescript/module/select/types.d.ts +6 -0
- package/lib/typescript/module/select/types.d.ts.map +1 -1
- package/lib/typescript/module/zoomable-image-gallery/ZoomableImageGallery.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/SelectItemLabel.test.tsx +46 -0
- 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 +31 -12
- 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 +92 -5
- package/src/select/types.ts +6 -0
- 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
|
|
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
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
if (
|
|
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
|
-
`${
|
|
370
|
-
'
|
|
371
|
-
'
|
|
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
|
-
|
|
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
|
@@ -1,5 +1,14 @@
|
|
|
1
|
-
import React, {
|
|
2
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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',
|
package/src/select/types.ts
CHANGED
|
@@ -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;
|