@oxyhq/bloom 0.32.1 → 0.33.1

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 (101) hide show
  1. package/lib/commonjs/command/Command.js +49 -2
  2. package/lib/commonjs/command/Command.js.map +1 -1
  3. package/lib/commonjs/hooks/index.js +13 -0
  4. package/lib/commonjs/hooks/index.js.map +1 -1
  5. package/lib/commonjs/hooks/useImagePreload.js +52 -0
  6. package/lib/commonjs/hooks/useImagePreload.js.map +1 -0
  7. package/lib/commonjs/image-aspect-ratio-cache/imageAspectRatioCache.js +65 -0
  8. package/lib/commonjs/image-aspect-ratio-cache/imageAspectRatioCache.js.map +1 -0
  9. package/lib/commonjs/image-aspect-ratio-cache/index.js +37 -0
  10. package/lib/commonjs/image-aspect-ratio-cache/index.js.map +1 -0
  11. package/lib/commonjs/index.js +15 -0
  12. package/lib/commonjs/index.js.map +1 -1
  13. package/lib/commonjs/index.web.js +15 -0
  14. package/lib/commonjs/index.web.js.map +1 -1
  15. package/lib/commonjs/zoomable-image-gallery/ZoomableImageGallery.js +664 -0
  16. package/lib/commonjs/zoomable-image-gallery/ZoomableImageGallery.js.map +1 -0
  17. package/lib/commonjs/zoomable-image-gallery/constants.js +41 -0
  18. package/lib/commonjs/zoomable-image-gallery/constants.js.map +1 -0
  19. package/lib/commonjs/zoomable-image-gallery/index.js +19 -0
  20. package/lib/commonjs/zoomable-image-gallery/index.js.map +1 -0
  21. package/lib/commonjs/zoomable-image-gallery/types.js +2 -0
  22. package/lib/commonjs/zoomable-image-gallery/types.js.map +1 -0
  23. package/lib/module/command/Command.js +50 -3
  24. package/lib/module/command/Command.js.map +1 -1
  25. package/lib/module/hooks/index.js +1 -0
  26. package/lib/module/hooks/index.js.map +1 -1
  27. package/lib/module/hooks/useImagePreload.js +48 -0
  28. package/lib/module/hooks/useImagePreload.js.map +1 -0
  29. package/lib/module/image-aspect-ratio-cache/imageAspectRatioCache.js +57 -0
  30. package/lib/module/image-aspect-ratio-cache/imageAspectRatioCache.js.map +1 -0
  31. package/lib/module/image-aspect-ratio-cache/index.js +4 -0
  32. package/lib/module/image-aspect-ratio-cache/index.js.map +1 -0
  33. package/lib/module/index.js +2 -0
  34. package/lib/module/index.js.map +1 -1
  35. package/lib/module/index.web.js +2 -0
  36. package/lib/module/index.web.js.map +1 -1
  37. package/lib/module/zoomable-image-gallery/ZoomableImageGallery.js +659 -0
  38. package/lib/module/zoomable-image-gallery/ZoomableImageGallery.js.map +1 -0
  39. package/lib/module/zoomable-image-gallery/constants.js +38 -0
  40. package/lib/module/zoomable-image-gallery/constants.js.map +1 -0
  41. package/lib/module/zoomable-image-gallery/index.js +4 -0
  42. package/lib/module/zoomable-image-gallery/index.js.map +1 -0
  43. package/lib/module/zoomable-image-gallery/types.js +2 -0
  44. package/lib/module/zoomable-image-gallery/types.js.map +1 -0
  45. package/lib/typescript/commonjs/command/Command.d.ts +14 -0
  46. package/lib/typescript/commonjs/command/Command.d.ts.map +1 -1
  47. package/lib/typescript/commonjs/hooks/index.d.ts +1 -0
  48. package/lib/typescript/commonjs/hooks/index.d.ts.map +1 -1
  49. package/lib/typescript/commonjs/hooks/useImagePreload.d.ts +10 -0
  50. package/lib/typescript/commonjs/hooks/useImagePreload.d.ts.map +1 -0
  51. package/lib/typescript/commonjs/image-aspect-ratio-cache/imageAspectRatioCache.d.ts +14 -0
  52. package/lib/typescript/commonjs/image-aspect-ratio-cache/imageAspectRatioCache.d.ts.map +1 -0
  53. package/lib/typescript/commonjs/image-aspect-ratio-cache/index.d.ts +2 -0
  54. package/lib/typescript/commonjs/image-aspect-ratio-cache/index.d.ts.map +1 -0
  55. package/lib/typescript/commonjs/index.d.ts +1 -0
  56. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  57. package/lib/typescript/commonjs/index.web.d.ts +1 -0
  58. package/lib/typescript/commonjs/index.web.d.ts.map +1 -1
  59. package/lib/typescript/commonjs/zoomable-image-gallery/ZoomableImageGallery.d.ts +5 -0
  60. package/lib/typescript/commonjs/zoomable-image-gallery/ZoomableImageGallery.d.ts.map +1 -0
  61. package/lib/typescript/commonjs/zoomable-image-gallery/constants.d.ts +33 -0
  62. package/lib/typescript/commonjs/zoomable-image-gallery/constants.d.ts.map +1 -0
  63. package/lib/typescript/commonjs/zoomable-image-gallery/index.d.ts +3 -0
  64. package/lib/typescript/commonjs/zoomable-image-gallery/index.d.ts.map +1 -0
  65. package/lib/typescript/commonjs/zoomable-image-gallery/types.d.ts +46 -0
  66. package/lib/typescript/commonjs/zoomable-image-gallery/types.d.ts.map +1 -0
  67. package/lib/typescript/module/command/Command.d.ts +14 -0
  68. package/lib/typescript/module/command/Command.d.ts.map +1 -1
  69. package/lib/typescript/module/hooks/index.d.ts +1 -0
  70. package/lib/typescript/module/hooks/index.d.ts.map +1 -1
  71. package/lib/typescript/module/hooks/useImagePreload.d.ts +10 -0
  72. package/lib/typescript/module/hooks/useImagePreload.d.ts.map +1 -0
  73. package/lib/typescript/module/image-aspect-ratio-cache/imageAspectRatioCache.d.ts +14 -0
  74. package/lib/typescript/module/image-aspect-ratio-cache/imageAspectRatioCache.d.ts.map +1 -0
  75. package/lib/typescript/module/image-aspect-ratio-cache/index.d.ts +2 -0
  76. package/lib/typescript/module/image-aspect-ratio-cache/index.d.ts.map +1 -0
  77. package/lib/typescript/module/index.d.ts +1 -0
  78. package/lib/typescript/module/index.d.ts.map +1 -1
  79. package/lib/typescript/module/index.web.d.ts +1 -0
  80. package/lib/typescript/module/index.web.d.ts.map +1 -1
  81. package/lib/typescript/module/zoomable-image-gallery/ZoomableImageGallery.d.ts +5 -0
  82. package/lib/typescript/module/zoomable-image-gallery/ZoomableImageGallery.d.ts.map +1 -0
  83. package/lib/typescript/module/zoomable-image-gallery/constants.d.ts +33 -0
  84. package/lib/typescript/module/zoomable-image-gallery/constants.d.ts.map +1 -0
  85. package/lib/typescript/module/zoomable-image-gallery/index.d.ts +3 -0
  86. package/lib/typescript/module/zoomable-image-gallery/index.d.ts.map +1 -0
  87. package/lib/typescript/module/zoomable-image-gallery/types.d.ts +46 -0
  88. package/lib/typescript/module/zoomable-image-gallery/types.d.ts.map +1 -0
  89. package/package.json +27 -1
  90. package/src/__tests__/Command.test.tsx +122 -1
  91. package/src/command/Command.tsx +50 -3
  92. package/src/hooks/index.ts +1 -0
  93. package/src/hooks/useImagePreload.ts +51 -0
  94. package/src/image-aspect-ratio-cache/imageAspectRatioCache.ts +62 -0
  95. package/src/image-aspect-ratio-cache/index.ts +7 -0
  96. package/src/index.ts +1 -0
  97. package/src/index.web.ts +1 -0
  98. package/src/zoomable-image-gallery/ZoomableImageGallery.tsx +695 -0
  99. package/src/zoomable-image-gallery/constants.ts +25 -0
  100. package/src/zoomable-image-gallery/index.ts +8 -0
  101. package/src/zoomable-image-gallery/types.ts +49 -0
@@ -0,0 +1 @@
1
+ {"version":3,"file":"constants.d.ts","sourceRoot":"","sources":["../../../../src/zoomable-image-gallery/constants.ts"],"names":[],"mappings":"AAEA,0EAA0E;AAC1E,eAAO,MAAM,YAAY,MAAM,CAAC;AAChC,oFAAoF;AACpF,eAAO,MAAM,iBAAiB,MAAM,CAAC;AACrC,kFAAkF;AAClF,eAAO,MAAM,mBAAmB,MAAM,CAAC;AACvC,uDAAuD;AACvD,eAAO,MAAM,cAAc,MAAM,CAAC;AAClC,kFAAkF;AAClF,eAAO,MAAM,gBAAgB,OAAO,CAAC;AACrC,0FAA0F;AAC1F,eAAO,MAAM,oBAAoB,KAAK,CAAC;AAEvC,eAAO,MAAM,iBAAiB,MAAM,CAAC;AACrC,eAAO,MAAM,kBAAkB,MAAM,CAAC;AACtC,eAAO,MAAM,gBAAgB,MAAM,CAAC;AAEpC,eAAO,MAAM,WAAW;;;;CAAsD,CAAC;AAC/E,eAAO,MAAM,YAAY;;;;CAAuD,CAAC;AACjF,eAAO,MAAM,gBAAgB;;;;CAAsD,CAAC;AAEpF,+EAA+E;AAC/E,eAAO,MAAM,qBAAqB,IAAsB,CAAC"}
@@ -0,0 +1,3 @@
1
+ export { ZoomableImageGallery, ZoomableImageGallery as default } from './ZoomableImageGallery';
2
+ export type { ZoomableImageGalleryHandle, ZoomableImageGalleryProps, GalleryImage, MeasureThumb, MeasuredRect, } from './types';
3
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../src/zoomable-image-gallery/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,oBAAoB,EAAE,oBAAoB,IAAI,OAAO,EAAE,MAAM,wBAAwB,CAAC;AAC/F,YAAY,EACV,0BAA0B,EAC1B,yBAAyB,EACzB,YAAY,EACZ,YAAY,EACZ,YAAY,GACb,MAAM,SAAS,CAAC"}
@@ -0,0 +1,46 @@
1
+ export interface GalleryImage {
2
+ /** Source URI rendered both as the thumbnail and the zoomed image. */
3
+ uri: string;
4
+ /**
5
+ * Author-authored accessibility description (Bluesky-style "ALT"). When present,
6
+ * shown as a caption at the bottom of the fullscreen viewer for the active image.
7
+ */
8
+ alt?: string;
9
+ /**
10
+ * Optional intrinsic aspect ratio (width / height) supplied by the consumer.
11
+ * When known ahead of time it avoids a first-frame `Image.getSize` round-trip.
12
+ */
13
+ aspectRatio?: number;
14
+ }
15
+ /**
16
+ * On-screen rectangle of a thumbnail, used to fly the zoom transition to/from
17
+ * the tapped image. Bloom-owned so consumers don't depend on an app-local shape.
18
+ */
19
+ export interface MeasuredRect {
20
+ x: number;
21
+ y: number;
22
+ width: number;
23
+ height: number;
24
+ }
25
+ /**
26
+ * Resolve the on-screen rect of the thumbnail at `index` within the SAME
27
+ * images-only subset the gallery opens/pages in, so the close animation can fly
28
+ * back to the image currently being viewed. Resolves `null` when the thumbnail
29
+ * ref is missing (unmounted/virtualized), in which case the gallery falls back
30
+ * to a plain center fade-out.
31
+ */
32
+ export type MeasureThumb = (index: number) => Promise<MeasuredRect | null>;
33
+ export interface ZoomableImageGalleryHandle {
34
+ /**
35
+ * Open the gallery at `index` within `images`, animating the zoom from the
36
+ * tapped thumbnail's measured screen rect (when provided).
37
+ */
38
+ open: (images: GalleryImage[], index: number, rect?: MeasuredRect) => void;
39
+ }
40
+ export interface ZoomableImageGalleryProps {
41
+ /** Measures any thumbnail by its images-only subset index, used on dismiss. */
42
+ measureThumb?: MeasureThumb;
43
+ /** Corner radius applied to the zoomed images. Defaults to `DEFAULT_CORNER_RADIUS`. */
44
+ cornerRadius?: number;
45
+ }
46
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../../../src/zoomable-image-gallery/types.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,YAAY;IAC3B,sEAAsE;IACtE,GAAG,EAAE,MAAM,CAAC;IACZ;;;OAGG;IACH,GAAG,CAAC,EAAE,MAAM,CAAC;IACb;;;OAGG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED;;;GAGG;AACH,MAAM,WAAW,YAAY;IAC3B,CAAC,EAAE,MAAM,CAAC;IACV,CAAC,EAAE,MAAM,CAAC;IACV,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;CAChB;AAED;;;;;;GAMG;AACH,MAAM,MAAM,YAAY,GAAG,CAAC,KAAK,EAAE,MAAM,KAAK,OAAO,CAAC,YAAY,GAAG,IAAI,CAAC,CAAC;AAE3E,MAAM,WAAW,0BAA0B;IACzC;;;OAGG;IACH,IAAI,EAAE,CAAC,MAAM,EAAE,YAAY,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,YAAY,KAAK,IAAI,CAAC;CAC5E;AAED,MAAM,WAAW,yBAAyB;IACxC,+EAA+E;IAC/E,YAAY,CAAC,EAAE,YAAY,CAAC;IAC5B,uFAAuF;IACvF,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxyhq/bloom",
3
- "version": "0.32.1",
3
+ "version": "0.33.1",
4
4
  "description": "Bloom UI — Oxy ecosystem component library for React Native + Expo + Web",
5
5
  "main": "lib/commonjs/index.js",
6
6
  "module": "lib/module/index.js",
@@ -39,6 +39,17 @@
39
39
  "default": "./lib/commonjs/image-resolver/index.js"
40
40
  }
41
41
  },
42
+ "./image-aspect-ratio-cache": {
43
+ "react-native": "./src/image-aspect-ratio-cache/index.ts",
44
+ "import": {
45
+ "types": "./lib/typescript/module/image-aspect-ratio-cache/index.d.ts",
46
+ "default": "./lib/module/image-aspect-ratio-cache/index.js"
47
+ },
48
+ "require": {
49
+ "types": "./lib/typescript/commonjs/image-aspect-ratio-cache/index.d.ts",
50
+ "default": "./lib/commonjs/image-aspect-ratio-cache/index.js"
51
+ }
52
+ },
42
53
  "./theme": {
43
54
  "react-native": "./src/theme/index.ts",
44
55
  "import": {
@@ -382,6 +393,17 @@
382
393
  "default": "./lib/commonjs/media-inset-border/index.js"
383
394
  }
384
395
  },
396
+ "./zoomable-image-gallery": {
397
+ "react-native": "./src/zoomable-image-gallery/index.ts",
398
+ "import": {
399
+ "types": "./lib/typescript/module/zoomable-image-gallery/index.d.ts",
400
+ "default": "./lib/module/zoomable-image-gallery/index.js"
401
+ },
402
+ "require": {
403
+ "types": "./lib/typescript/commonjs/zoomable-image-gallery/index.d.ts",
404
+ "default": "./lib/commonjs/zoomable-image-gallery/index.js"
405
+ }
406
+ },
385
407
  "./pressable-scale": {
386
408
  "react-native": "./src/pressable-scale/index.ts",
387
409
  "import": {
@@ -1054,8 +1076,10 @@
1054
1076
  "@types/react-dom": "^19.2.3",
1055
1077
  "@types/react-native": "*",
1056
1078
  "@vitejs/plugin-react": "^6.0.2",
1079
+ "expo-blur": "~56.0.3",
1057
1080
  "expo-font": "^56.0.5",
1058
1081
  "expo-haptics": "~56.0.3",
1082
+ "expo-image": "~56.0.11",
1059
1083
  "expo-router": "^56.0.0",
1060
1084
  "jest": "^30.3.0",
1061
1085
  "jest-environment-jsdom": "^30.4.1",
@@ -1079,8 +1103,10 @@
1079
1103
  },
1080
1104
  "peerDependencies": {
1081
1105
  "expo": "*",
1106
+ "expo-blur": "*",
1082
1107
  "expo-font": "*",
1083
1108
  "expo-haptics": "*",
1109
+ "expo-image": "*",
1084
1110
  "expo-router": ">=3.0.0",
1085
1111
  "react": ">=18.0.0",
1086
1112
  "react-dom": ">=18.0.0",
@@ -1,8 +1,10 @@
1
- import React from 'react';
1
+ import React, { useImperativeHandle } from 'react';
2
2
  import { act, fireEvent, render } from '@testing-library/react-native';
3
3
 
4
4
  import { Command } from '../command';
5
+ import { createCommand } from '../command/Command';
5
6
  import type { CommandItem } from '../command';
7
+ import type { DialogProps } from '../dialog';
6
8
  import { BloomThemeProvider } from '../theme/BloomThemeProvider';
7
9
 
8
10
  function renderWithTheme(ui: React.ReactElement) {
@@ -104,4 +106,123 @@ describe('Command (built on Dialog)', () => {
104
106
  expect(onSelect).toHaveBeenCalledTimes(1);
105
107
  expect(onClose).toHaveBeenCalledTimes(1);
106
108
  });
109
+
110
+ // The migration off the controlled `open` prop onto the imperative `control`
111
+ // handle (making `Command` the last controlled-mode `Dialog` consumer to move
112
+ // over). These pin the fix at the prop boundary between `Command` and the
113
+ // underlying `Dialog`, using a prop-capturing mock `Dialog` so the assertions
114
+ // are deterministic and platform-agnostic — mirroring `AlertDialog`'s round.
115
+ describe('control-mode bridge', () => {
116
+ function captureDialogProps() {
117
+ const captured: { props?: DialogProps } = {};
118
+ const MockDialog = (props: DialogProps) => {
119
+ captured.props = props;
120
+ return null;
121
+ };
122
+ return { captured, MockDialog };
123
+ }
124
+
125
+ it('drives the underlying Dialog imperatively (control), never the controlled `open` prop', () => {
126
+ const { captured, MockDialog } = captureDialogProps();
127
+ const TestCommand = createCommand(MockDialog);
128
+ renderWithTheme(
129
+ <TestCommand visible onClose={() => {}} items={makeItems(() => {})} />,
130
+ );
131
+ // The whole point of the migration: `Command` must take the Dialog's
132
+ // imperative/uncontrolled branch (the one that plays the exit animation on
133
+ // dismiss), so an imperative `control` handle is passed and the controlled
134
+ // `open` boolean — which fires `onClose` synchronously — is never set.
135
+ expect(captured.props?.control).toBeDefined();
136
+ expect(captured.props?.open).toBeUndefined();
137
+ });
138
+
139
+ // A mock Dialog that registers the real imperative contract on `control.ref`
140
+ // (so `Command`'s `control.open()` / `control.close()` proxy through it) and
141
+ // fires `onClose` when imperatively closed — modelling the real Dialog,
142
+ // which invokes `onClose` only AFTER its exit animation settles.
143
+ function ImperativeMockDialog(props: DialogProps) {
144
+ const onCloseRef = React.useRef(props.onClose);
145
+ onCloseRef.current = props.onClose;
146
+ useImperativeHandle(
147
+ props.control?.ref,
148
+ () => ({
149
+ open: () => {},
150
+ // The real Dialog fires `onClose` post-exit-animation.
151
+ close: () => onCloseRef.current?.(),
152
+ }),
153
+ [],
154
+ );
155
+ return null;
156
+ }
157
+
158
+ it('opens the Dialog imperatively on mount when visible, and closes it when visible flips false', () => {
159
+ const spies = { open: jest.fn(), close: jest.fn() };
160
+ const MockDialog = (props: DialogProps) => {
161
+ useImperativeHandle(props.control?.ref, () => spies, []);
162
+ return null;
163
+ };
164
+ const TestCommand = createCommand(MockDialog);
165
+ const { rerender } = renderWithTheme(
166
+ <TestCommand visible onClose={() => {}} items={makeItems(() => {})} />,
167
+ );
168
+ expect(spies.open).toHaveBeenCalledTimes(1);
169
+ expect(spies.close).not.toHaveBeenCalled();
170
+
171
+ act(() => {
172
+ rerender(
173
+ <BloomThemeProvider mode="light" colorPreset="teal">
174
+ <TestCommand
175
+ visible={false}
176
+ onClose={() => {}}
177
+ items={makeItems(() => {})}
178
+ />
179
+ </BloomThemeProvider>,
180
+ );
181
+ });
182
+ expect(spies.close).toHaveBeenCalledTimes(1);
183
+ });
184
+
185
+ it('forwards a user dismissal (backdrop / Escape) through onClose', () => {
186
+ const spyRef: { fn?: () => void } = {};
187
+ const MockDialog = (props: DialogProps) => {
188
+ spyRef.fn = props.onClose;
189
+ return null;
190
+ };
191
+ const onClose = jest.fn();
192
+ const TestCommand = createCommand(MockDialog);
193
+ renderWithTheme(
194
+ <TestCommand visible onClose={onClose} items={makeItems(() => {})} />,
195
+ );
196
+ // The real Dialog fires `onClose` once its exit animation settles after a
197
+ // backdrop / Escape dismissal. `Command` must forward that to the consumer.
198
+ act(() => {
199
+ spyRef.fn?.();
200
+ });
201
+ expect(onClose).toHaveBeenCalledTimes(1);
202
+ });
203
+
204
+ it('does not re-enter onClose when the consumer closes it (visible → false)', () => {
205
+ const onClose = jest.fn();
206
+ const TestCommand = createCommand(ImperativeMockDialog);
207
+ const { rerender } = renderWithTheme(
208
+ <TestCommand visible onClose={onClose} items={makeItems(() => {})} />,
209
+ );
210
+ act(() => {
211
+ rerender(
212
+ <BloomThemeProvider mode="light" colorPreset="teal">
213
+ <TestCommand
214
+ visible={false}
215
+ onClose={onClose}
216
+ items={makeItems(() => {})}
217
+ />
218
+ </BloomThemeProvider>,
219
+ );
220
+ });
221
+ // A consumer-initiated close flips `visible` to false → `Command`
222
+ // imperatively `close()`s the Dialog (which fires its post-exit `onClose`).
223
+ // That programmatic close must NOT re-enter the consumer's `onClose`,
224
+ // preserving the previous controlled semantics.
225
+ expect(onClose).not.toHaveBeenCalled();
226
+ });
227
+ });
107
228
  });
@@ -1,4 +1,4 @@
1
- import React, { useCallback, useMemo, useRef, useState } from 'react';
1
+ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
2
  import {
3
3
  Platform,
4
4
  ScrollView,
@@ -15,6 +15,7 @@ import { Kbd } from '../kbd';
15
15
  import { SearchInput } from '../search-input';
16
16
  import { fontSize, space } from '../styles/tokens';
17
17
  import type { DialogProps } from '../dialog';
18
+ import { useDialogControl } from '../dialog/context';
18
19
  import type { CommandItem, CommandProps } from './types';
19
20
 
20
21
  type DialogComponent = React.ComponentType<DialogProps>;
@@ -48,6 +49,20 @@ interface FlatEntry {
48
49
  * `SearchInput`, an `Item` results list and `Kbd` shortcut hints. Items group
49
50
  * by their `group` field (ungrouped first). On web the list is
50
51
  * keyboard-navigable (Up/Down/Enter); on native, tap to select.
52
+ *
53
+ * Control mode — IMPERATIVE, not controlled. `Command` keeps its public
54
+ * *controlled* API (`visible` boolean + `onClose`) but internally bridges it
55
+ * onto the Dialog's imperative `useDialogControl()` handle, exactly like
56
+ * `AlertDialog`. This makes `Command` the LAST controlled-`open` `Dialog`
57
+ * consumer to move onto the imperative path every other surface uses
58
+ * (`alert()`, `confirm()`, native `Menu`/`Select`/`ContextMenu`, Mention's
59
+ * dialogs), so there is one code path across the ecosystem. It also guarantees
60
+ * the palette's exit animation on the dismiss path even for a consumer that
61
+ * unmounts the palette in response to `onClose` (`{open && <Command …/>}`): the
62
+ * imperative `close()` runs the exit animation FIRST and fires `onClose` only
63
+ * once it settles, whereas the controlled `open` path fires `onClose`
64
+ * synchronously — which, for an unmount-on-close consumer, would tear the
65
+ * surface down before it could animate out.
51
66
  */
52
67
  export function createCommand(Dialog: DialogComponent) {
53
68
  const Command = function Command({
@@ -64,6 +79,7 @@ export function createCommand(Dialog: DialogComponent) {
64
79
  testID,
65
80
  }: CommandProps) {
66
81
  const theme = useTheme();
82
+ const control = useDialogControl();
67
83
  const searchRef = useRef<TextInput>(null);
68
84
  const [activeIndex, setActiveIndex] = useState(0);
69
85
 
@@ -142,6 +158,37 @@ export function createCommand(Dialog: DialogComponent) {
142
158
  [setQuery, onClose],
143
159
  );
144
160
 
161
+ // Bridge the public *controlled* `visible` prop onto the Dialog's imperative
162
+ // open/close (mirrors `AlertDialog`). `control` is referentially stable
163
+ // (memoised on its id), so this effect only re-runs when `visible` actually
164
+ // flips. Opening straight from an effect on mount matches bloom's own
165
+ // `AutoMountedDialog`. When a consumer flips `visible` to `false` (their own
166
+ // close, or after a select), we imperatively `close()` so the exit animation
167
+ // still plays.
168
+ const closingFromPropRef = useRef(false);
169
+ useEffect(() => {
170
+ if (visible) {
171
+ control.open();
172
+ return;
173
+ }
174
+ closingFromPropRef.current = true;
175
+ control.close();
176
+ }, [visible, control]);
177
+
178
+ // The Dialog fires `onClose` after the exit animation settles (imperative
179
+ // mode). Forward ONLY user-initiated dismissals (backdrop / Escape) to the
180
+ // consumer — a consumer-initiated close (they flipped `visible` to `false`,
181
+ // e.g. `select` already called `onClose`) must not re-enter `onClose`,
182
+ // preserving the previous controlled semantics where a programmatic close
183
+ // does not fire `onClose` a second time.
184
+ const handleClose = useCallback(() => {
185
+ if (closingFromPropRef.current) {
186
+ closingFromPropRef.current = false;
187
+ return;
188
+ }
189
+ onClose();
190
+ }, [onClose]);
191
+
145
192
  // Web keyboard navigation on the search input.
146
193
  const webKeyHandler: Record<string, unknown> =
147
194
  Platform.OS === 'web'
@@ -172,8 +219,8 @@ export function createCommand(Dialog: DialogComponent) {
172
219
 
173
220
  return (
174
221
  <Dialog
175
- open={visible}
176
- onClose={onClose}
222
+ control={control}
223
+ onClose={handleClose}
177
224
  placement="center"
178
225
  dismissOnBackdrop
179
226
  maxWidth={COMMAND_MAX_WIDTH}
@@ -7,3 +7,4 @@ export { useHaptics, BloomHapticsProvider } from './useHaptics';
7
7
  export type { HapticStrength, BloomHapticsProviderProps } from './useHaptics';
8
8
  export { useGutters } from './useGutters';
9
9
  export type { Gutter, Gutters } from './useGutters';
10
+ export { useImagePreload, preloadImage } from './useImagePreload';
@@ -0,0 +1,51 @@
1
+ import { useEffect, useRef, useMemo } from 'react';
2
+ import { Image } from 'react-native';
3
+
4
+ /**
5
+ * Hook to preload images for better perceived performance
6
+ * Preloads images when they're likely to be viewed soon
7
+ */
8
+ export function useImagePreload(urls: (string | undefined)[], enabled: boolean = true) {
9
+ const preloadedRef = useRef<Set<string>>(new Set());
10
+
11
+ // Memoize valid URLs to avoid unnecessary re-runs
12
+ const validUrls = useMemo(() => {
13
+ return urls.filter((url): url is string =>
14
+ Boolean(url && (url.startsWith('http://') || url.startsWith('https://')))
15
+ );
16
+ }, [urls]);
17
+
18
+ useEffect(() => {
19
+ if (!enabled || !validUrls.length) return;
20
+
21
+ validUrls.forEach((url) => {
22
+ if (preloadedRef.current.has(url)) return;
23
+
24
+ preloadedRef.current.add(url);
25
+ Image.prefetch(url).catch(() => {
26
+ // Silently handle prefetch errors - remove from set on error to allow retry
27
+ preloadedRef.current.delete(url);
28
+ });
29
+ });
30
+ }, [validUrls, enabled]);
31
+
32
+ // Limit cache size to prevent memory issues (keep last 1000)
33
+ useEffect(() => {
34
+ if (preloadedRef.current.size > 1000) {
35
+ const entries = Array.from(preloadedRef.current);
36
+ const toRemove = entries.slice(0, entries.length - 1000);
37
+ toRemove.forEach(url => preloadedRef.current.delete(url));
38
+ }
39
+ }, [validUrls]);
40
+ }
41
+
42
+ /**
43
+ * Preload a single image
44
+ */
45
+ export function preloadImage(url: string | undefined): void {
46
+ if (!url || !url.startsWith('http')) return;
47
+
48
+ Image.prefetch(url).catch(() => {
49
+ // Silently handle prefetch errors
50
+ });
51
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Shared module-scoped cache of image aspect ratios (width / height), keyed by
3
+ * the image source URI.
4
+ *
5
+ * Intended for a thumbnail renderer and a fullscreen viewer (e.g.
6
+ * `@oxyhq/bloom/zoomable-image-gallery`) sharing the SAME image URI to read
7
+ * from and write to this one map, so an image already resolved as a thumbnail
8
+ * has its ratio on hand when it opens "big" — no second cache, no duplicate
9
+ * `Image.getSize` round-trip on the hot path. If the two surfaces render
10
+ * different URI variants of the same image (e.g. a smaller thumbnail variant
11
+ * vs. a larger lightbox variant), pass the known ratio through explicitly
12
+ * (`GalleryImage.aspectRatio`) instead of relying on cache-key reuse.
13
+ */
14
+ import { Image } from 'react-native';
15
+
16
+ /** Fallback ratio used when a remote image's intrinsic size cannot be read. */
17
+ export const DEFAULT_ASPECT_RATIO = 4 / 3;
18
+
19
+ const aspectRatioCache = new Map<string, number>();
20
+
21
+ export const getAspectRatio = (uri: string): number | undefined => aspectRatioCache.get(uri);
22
+
23
+ export const hasAspectRatio = (uri: string): boolean => aspectRatioCache.has(uri);
24
+
25
+ export const setAspectRatio = (uri: string, ratio: number): void => {
26
+ if (uri && ratio > 0 && Number.isFinite(ratio)) {
27
+ aspectRatioCache.set(uri, ratio);
28
+ }
29
+ };
30
+
31
+ /**
32
+ * Resolve the aspect ratio for `uri`, returning the cached value immediately on
33
+ * a hit and otherwise fetching the intrinsic size via `Image.getSize`, writing
34
+ * the result back into the shared cache. On failure the shared
35
+ * `DEFAULT_ASPECT_RATIO` is cached and returned so callers never deadlock on a
36
+ * broken image.
37
+ */
38
+ export const fetchAspectRatio = (uri: string): Promise<number> => {
39
+ const cached = aspectRatioCache.get(uri);
40
+ if (cached !== undefined) {
41
+ return Promise.resolve(cached);
42
+ }
43
+ return new Promise<number>((resolve) => {
44
+ Image.getSize(
45
+ uri,
46
+ (width, height) => {
47
+ if (width > 0 && height > 0) {
48
+ const ratio = width / height;
49
+ aspectRatioCache.set(uri, ratio);
50
+ resolve(ratio);
51
+ return;
52
+ }
53
+ aspectRatioCache.set(uri, DEFAULT_ASPECT_RATIO);
54
+ resolve(DEFAULT_ASPECT_RATIO);
55
+ },
56
+ () => {
57
+ aspectRatioCache.set(uri, DEFAULT_ASPECT_RATIO);
58
+ resolve(DEFAULT_ASPECT_RATIO);
59
+ }
60
+ );
61
+ });
62
+ };
@@ -0,0 +1,7 @@
1
+ export {
2
+ getAspectRatio,
3
+ hasAspectRatio,
4
+ setAspectRatio,
5
+ fetchAspectRatio,
6
+ DEFAULT_ASPECT_RATIO,
7
+ } from './imageAspectRatioCache';
package/src/index.ts CHANGED
@@ -44,6 +44,7 @@ export { useHaptics, BloomHapticsProvider } from './hooks/useHaptics';
44
44
  export type { HapticStrength, BloomHapticsProviderProps } from './hooks/useHaptics';
45
45
  export { useGutters } from './hooks/useGutters';
46
46
  export type { Gutter, Gutters } from './hooks/useGutters';
47
+ export { useImagePreload, preloadImage } from './hooks/useImagePreload';
47
48
 
48
49
  // Icons
49
50
  export * as Icons from './icons';
package/src/index.web.ts CHANGED
@@ -49,6 +49,7 @@ export { useHaptics, BloomHapticsProvider } from './hooks/useHaptics';
49
49
  export type { HapticStrength, BloomHapticsProviderProps } from './hooks/useHaptics';
50
50
  export { useGutters } from './hooks/useGutters';
51
51
  export type { Gutter, Gutters } from './hooks/useGutters';
52
+ export { useImagePreload, preloadImage } from './hooks/useImagePreload';
52
53
 
53
54
  // Icons
54
55
  export * as Icons from './icons';