@oxyhq/bloom 0.31.0 → 0.32.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 (40) hide show
  1. package/lib/commonjs/alert-dialog/AlertDialog.js +69 -21
  2. package/lib/commonjs/alert-dialog/AlertDialog.js.map +1 -1
  3. package/lib/commonjs/button/Button.web.js +6 -29
  4. package/lib/commonjs/button/Button.web.js.map +1 -1
  5. package/lib/commonjs/fab/Fab.web.js +11 -2
  6. package/lib/commonjs/fab/Fab.web.js.map +1 -1
  7. package/lib/commonjs/styles/flatten-web-style.js +49 -0
  8. package/lib/commonjs/styles/flatten-web-style.js.map +1 -0
  9. package/lib/module/alert-dialog/AlertDialog.js +70 -22
  10. package/lib/module/alert-dialog/AlertDialog.js.map +1 -1
  11. package/lib/module/button/Button.web.js +6 -29
  12. package/lib/module/button/Button.web.js.map +1 -1
  13. package/lib/module/fab/Fab.web.js +11 -2
  14. package/lib/module/fab/Fab.web.js.map +1 -1
  15. package/lib/module/styles/flatten-web-style.js +45 -0
  16. package/lib/module/styles/flatten-web-style.js.map +1 -0
  17. package/lib/typescript/commonjs/alert-dialog/AlertDialog.d.ts +13 -0
  18. package/lib/typescript/commonjs/alert-dialog/AlertDialog.d.ts.map +1 -1
  19. package/lib/typescript/commonjs/alert-dialog/types.d.ts +8 -2
  20. package/lib/typescript/commonjs/alert-dialog/types.d.ts.map +1 -1
  21. package/lib/typescript/commonjs/button/Button.web.d.ts.map +1 -1
  22. package/lib/typescript/commonjs/fab/Fab.web.d.ts.map +1 -1
  23. package/lib/typescript/commonjs/styles/flatten-web-style.d.ts +6 -0
  24. package/lib/typescript/commonjs/styles/flatten-web-style.d.ts.map +1 -0
  25. package/lib/typescript/module/alert-dialog/AlertDialog.d.ts +13 -0
  26. package/lib/typescript/module/alert-dialog/AlertDialog.d.ts.map +1 -1
  27. package/lib/typescript/module/alert-dialog/types.d.ts +8 -2
  28. package/lib/typescript/module/alert-dialog/types.d.ts.map +1 -1
  29. package/lib/typescript/module/button/Button.web.d.ts.map +1 -1
  30. package/lib/typescript/module/fab/Fab.web.d.ts.map +1 -1
  31. package/lib/typescript/module/styles/flatten-web-style.d.ts +6 -0
  32. package/lib/typescript/module/styles/flatten-web-style.d.ts.map +1 -0
  33. package/package.json +1 -1
  34. package/src/__tests__/AlertDialog.test.tsx +72 -0
  35. package/src/__tests__/Fab.web.test.tsx +62 -0
  36. package/src/alert-dialog/AlertDialog.tsx +68 -21
  37. package/src/alert-dialog/types.ts +8 -2
  38. package/src/button/Button.web.tsx +6 -36
  39. package/src/fab/Fab.web.tsx +11 -2
  40. package/src/styles/flatten-web-style.ts +58 -0
@@ -1,6 +1,7 @@
1
- import React, { memo, useCallback } from 'react';
1
+ import React, { memo, useCallback, useEffect, useRef } from 'react';
2
2
 
3
3
  import type { DialogAction, DialogProps } from '../dialog';
4
+ import { useDialogControl } from '../dialog/context';
4
5
  import type { AlertDialogProps } from './types';
5
6
 
6
7
  type DialogComponent = React.ComponentType<DialogProps>;
@@ -22,6 +23,19 @@ type DialogComponent = React.ComponentType<DialogProps>;
22
23
  * imperative `confirm()` helper + `<AlertDialogHost />` to trigger a confirm
23
24
  * from an event handler without owning visible state.
24
25
  *
26
+ * Control mode — IMPERATIVE, not controlled. `AlertDialog` keeps its public
27
+ * *controlled* API (`visible` boolean + `onClose`) but internally bridges it
28
+ * onto the Dialog's imperative `useDialogControl()` handle, so `confirm()`
29
+ * drives `<Dialog>` through the EXACT uncontrolled/imperative internals
30
+ * Mention's `ConfirmPrompt` uses. That is the code path that actually plays the
31
+ * exit animation on dismiss: imperative `close()` sets `isClosing`, runs the
32
+ * exit animation, and fires `onClose` only once it settles. The controlled path
33
+ * instead fires `onClose` synchronously — and because `<AlertDialogHost />`
34
+ * resolves + drops the queue entry inside `onClose`, the surface would then
35
+ * unmount instantly, skipping the exit animation entirely. Bridging to
36
+ * imperative mode eliminates that fork so `confirm()` and Mention's
37
+ * `ConfirmPrompt` are pixel/timing identical.
38
+ *
25
39
  * The centered card uses the Dialog default `maxWidth` (480px) for full visual
26
40
  * parity with the shared confirm surface.
27
41
  */
@@ -37,34 +51,64 @@ export function createAlertDialog(Dialog: DialogComponent) {
37
51
  onCancel,
38
52
  destructive = false,
39
53
  hideCancel = false,
40
- dismissible = false,
54
+ dismissible = true,
41
55
  cardStyle,
42
56
  testID,
43
57
  }: AlertDialogProps) {
44
- // Confirm resolves + closes SYNCHRONOUSLY, in this order, and must NOT go
45
- // through the Dialog's auto-close (`shouldCloseOnPress: false`). In
46
- // controlled mode `close()` fires `onClose` FIRST, and the `confirm()` host
47
- // wires `onClose` to resolve the promise as *cancelled* (`false`). Running
48
- // `onConfirm` (resolve `true`, which removes the queue entry) before
49
- // `onClose` (now a no-op for that entry) is what makes the confirm button
50
- // resolve `true`.
51
- const handleConfirm = useCallback(() => {
52
- onConfirm?.();
58
+ const control = useDialogControl();
59
+
60
+ // Bridge the public *controlled* `visible` prop onto the Dialog's
61
+ // imperative open/close. Opening on mount straight from an effect (no
62
+ // `setTimeout`) mirrors bloom's own `AutoMountedDialog` (which backs
63
+ // `alert()`) the canonical fresh-mount imperative-open pattern:
64
+ // `<AlertDialogHost />` mounts a FRESH `AlertDialog` per confirm
65
+ // (`key={id}`) with the final content already committed, so there is no
66
+ // stale-content window to defer past and the entry animation curve stays
67
+ // identical to Mention's. `control` is referentially stable (memoised on
68
+ // its id), so this effect only re-runs when `visible` actually flips. When
69
+ // a direct consumer flips `visible` to `false`, we imperatively `close()`
70
+ // so the exit animation still plays.
71
+ const closingFromPropRef = useRef(false);
72
+ useEffect(() => {
73
+ if (visible) {
74
+ control.open();
75
+ return;
76
+ }
77
+ closingFromPropRef.current = true;
78
+ control.close();
79
+ }, [visible, control]);
80
+
81
+ // The Dialog fires `onClose` after the exit animation settles (imperative
82
+ // mode). Forward ONLY user-initiated dismissals (backdrop / Escape / cancel)
83
+ // to the consumer — a consumer-initiated close (they flipped `visible` to
84
+ // `false`) must not re-enter `onClose`, preserving the previous controlled
85
+ // semantics where a programmatic close does not fire `onClose`.
86
+ const handleClose = useCallback(() => {
87
+ if (closingFromPropRef.current) {
88
+ closingFromPropRef.current = false;
89
+ return;
90
+ }
53
91
  onClose();
54
- }, [onConfirm, onClose]);
92
+ }, [onClose]);
55
93
 
94
+ // Confirm/cancel map onto the Dialog's declarative action row. Both keep the
95
+ // default `shouldCloseOnPress` (true): the button runs the exit animation
96
+ // FIRST, then invokes `onPress` as a close-completion callback — so
97
+ // `onConfirm` resolves the `confirm()` promise as the surface animates out,
98
+ // exactly like Mention. `onConfirm` (resolve `true`, drains the entry) runs
99
+ // just BEFORE the Dialog's own post-close `onClose` (→ `handleClose` →
100
+ // resolve `false`, now a no-op for the drained entry), which is what makes
101
+ // the confirm button win the double-resolution guard.
56
102
  const actions: DialogAction[] = [
57
103
  {
58
104
  label: confirmLabel,
59
- onPress: handleConfirm,
105
+ onPress: onConfirm,
60
106
  color: destructive ? 'destructive' : 'default',
61
- shouldCloseOnPress: false,
62
107
  },
63
108
  ];
64
109
  if (!hideCancel) {
65
- // The `cancel` color auto-dismisses via the Dialog's `close()`, which in
66
- // controlled mode fires `onClose` (the host resolves `false`). `onCancel`
67
- // runs after the surface finishes closing.
110
+ // The `cancel` color auto-dismisses via the Dialog's `close()` (exit
111
+ // animation), then runs `onCancel` as a close-completion callback.
68
112
  actions.push({
69
113
  label: cancelLabel,
70
114
  onPress: onCancel,
@@ -74,11 +118,14 @@ export function createAlertDialog(Dialog: DialogComponent) {
74
118
 
75
119
  return (
76
120
  <Dialog
77
- open={visible}
78
- onClose={onClose}
121
+ control={control}
122
+ onClose={handleClose}
79
123
  placement="center"
80
- // Alert dialogs are blocking by default the backdrop / Escape only
81
- // dismiss when the caller opts in via `dismissible`.
124
+ // Alert dialogs dismiss on backdrop / Escape by default (matching the
125
+ // shared Dialog default and Mention's `ConfirmPrompt`). A backdrop /
126
+ // Escape dismissal can only ever resolve the confirm as cancelled
127
+ // (`false`), never the destructive action — callers that need a truly
128
+ // blocking confirm opt out with `dismissible={false}`.
82
129
  dismissOnBackdrop={dismissible}
83
130
  title={title}
84
131
  description={description}
@@ -27,8 +27,10 @@ export interface AlertDialogProps {
27
27
  /** Render only the confirm button (no cancel). Defaults to `false`. */
28
28
  hideCancel?: boolean;
29
29
  /**
30
- * Tap-outside / Escape dismisses. Defaults to `false` — alert dialogs are
31
- * usually blocking until answered.
30
+ * Tap-outside / Escape dismisses. Defaults to `true` — matching the shared
31
+ * `Dialog` default and Mention's `ConfirmPrompt`. A backdrop / Escape
32
+ * dismissal always resolves the confirm as cancelled (`false`), so it is
33
+ * safe by default; pass `false` for a truly blocking confirm.
32
34
  */
33
35
  dismissible?: boolean;
34
36
  cardStyle?: StyleProp<ViewStyle>;
@@ -42,5 +44,9 @@ export interface ConfirmOptions {
42
44
  cancelLabel?: string;
43
45
  destructive?: boolean;
44
46
  hideCancel?: boolean;
47
+ /**
48
+ * Tap-outside / Escape dismisses (resolving `false`). Defaults to `true`;
49
+ * pass `false` for a blocking confirm that must be answered by a button.
50
+ */
45
51
  dismissible?: boolean;
46
52
  }
@@ -9,11 +9,10 @@ import React, {
9
9
  type ReactElement,
10
10
  } from 'react';
11
11
 
12
- import type { ViewStyle } from 'react-native';
13
-
14
12
  import { useTheme } from '../theme/use-theme';
15
13
  import type { Theme } from '../theme/types';
16
14
  import { SpinnerIcon } from '../loading/SpinnerIcon.web';
15
+ import { flattenWebStyle } from '../styles/flatten-web-style';
17
16
  import type { ButtonProps, ButtonSize, ButtonVariant } from './types';
18
17
 
19
18
  export type { ButtonProps, ButtonVariant, ButtonSize } from './types';
@@ -218,37 +217,6 @@ function resolveVariantStyle(
218
217
  }
219
218
  }
220
219
 
221
- // ---------------------------------------------------------------------------
222
- // Style normalization
223
- //
224
- // Callers idiomatically pass `style` as a React-Native `StyleProp` — most
225
- // often an ARRAY (`style={[base, cond && override]}`, possibly nested and
226
- // containing falsy holes from `cond && {...}`). The DOM `<button>` below needs
227
- // a SINGLE plain object: spreading a raw array into an object literal produces
228
- // numeric keys (`"0"`, `"1"`, …), which React then assigns onto the real
229
- // `CSSStyleDeclaration`, throwing
230
- // `Failed to set an indexed property [0] on 'CSSStyleDeclaration'`. Flatten the
231
- // StyleProp into one object here, once, before it reaches either merge site.
232
- // Later entries win, mirroring React-Native array-style precedence.
233
- //
234
- // `StyleNode` is a readonly-closed recursion type (RN's own `StyleProp` array
235
- // member yields `readonly` sub-arrays that aren't assignable back to
236
- // `StyleProp`, so it can't be recursed over directly). `ButtonProps['style']`
237
- // is assignable to it, so call sites pass `style` with no cast.
238
- // ---------------------------------------------------------------------------
239
-
240
- type StyleNode = ViewStyle | false | null | undefined | '' | ReadonlyArray<StyleNode>;
241
-
242
- function flattenStyle(style: StyleNode): CSSProperties {
243
- if (Array.isArray(style)) {
244
- return style.reduce<CSSProperties>(
245
- (merged, entry) => Object.assign(merged, flattenStyle(entry)),
246
- {},
247
- );
248
- }
249
- return style && typeof style === 'object' ? (style as CSSProperties) : {};
250
- }
251
-
252
220
  // ---------------------------------------------------------------------------
253
221
  // Component
254
222
  // ---------------------------------------------------------------------------
@@ -350,9 +318,11 @@ const ButtonWebComponent: React.FC<ButtonProps> = ({
350
318
 
351
319
  const spinnerColor = loadingColor ?? variantStyle.textColor;
352
320
 
353
- // Normalize the caller's `style` (single object, array, or falsy) into one
354
- // flat plain object ONCE, so neither merge site below spreads a raw array.
355
- const resolvedStyle = flattenStyle(style);
321
+ // Normalize the caller's `style` (single object, StyleProp array, or falsy)
322
+ // into ONE flat plain object here, once, so neither raw-DOM merge site below
323
+ // spreads a StyleProp array (which would leak numeric keys onto the button's
324
+ // CSSStyleDeclaration). See `flattenWebStyle` for the full rationale.
325
+ const resolvedStyle = flattenWebStyle(style);
356
326
 
357
327
  const content = (
358
328
  <>
@@ -10,6 +10,7 @@ import React, {
10
10
 
11
11
  import { useTheme } from '../theme/use-theme';
12
12
  import type { Theme } from '../theme/types';
13
+ import { flattenWebStyle } from '../styles/flatten-web-style';
13
14
  import type { FabPlacement, FabProps, FabSize, FabVariant } from './types';
14
15
 
15
16
  export type { FabProps, FabVariant, FabSize, FabPlacement } from './types';
@@ -261,12 +262,20 @@ const FabWebComponent: React.FC<FabProps> = ({
261
262
  const ariaLabel = ariaLabelProp ?? accessibilityLabel ?? label;
262
263
  const composedClassName = ['bloom-fab'].concat(className ? [className] : []).join(' ');
263
264
 
265
+ // Normalize `style`/`labelStyle` (single object, StyleProp array — the native
266
+ // fork passes `style={[placementStyle(...), {...}, style]}` — or falsy) into
267
+ // ONE flat plain object each, so neither raw-DOM merge site below spreads a
268
+ // StyleProp array (which would leak numeric keys onto the element's
269
+ // CSSStyleDeclaration). See `flattenWebStyle` for the full rationale.
270
+ const resolvedStyle = flattenWebStyle(style);
271
+ const resolvedLabelStyle = flattenWebStyle(labelStyle);
272
+
264
273
  return (
265
274
  <button
266
275
  id={resolvedId}
267
276
  type={type}
268
277
  className={composedClassName}
269
- style={{ ...containerStyle, ...(style as CSSProperties) }}
278
+ style={{ ...containerStyle, ...resolvedStyle }}
270
279
  onClick={handleClick}
271
280
  disabled={disabled}
272
281
  aria-disabled={disabled || undefined}
@@ -288,7 +297,7 @@ const FabWebComponent: React.FC<FabProps> = ({
288
297
  {content}
289
298
  </span>
290
299
  )}
291
- {isExtended && <span style={labelStyle as CSSProperties}>{label}</span>}
300
+ {isExtended && <span style={resolvedLabelStyle}>{label}</span>}
292
301
  </button>
293
302
  );
294
303
  };
@@ -0,0 +1,58 @@
1
+ import type { CSSProperties } from 'react';
2
+ import type { TextStyle, ViewStyle } from 'react-native';
3
+
4
+ // ---------------------------------------------------------------------------
5
+ // flattenWebStyle — StyleProp → plain CSSProperties, for raw-DOM web forks.
6
+ //
7
+ // A handful of Bloom's `.web.tsx` forks (`Button.web.tsx`, `Fab.web.tsx`)
8
+ // render REAL HTML elements through react-dom (`<button>`, `<span>`), NOT
9
+ // react-native-web components. Their `style` prop is therefore a single plain
10
+ // `CSSProperties` object that React commits straight to a live
11
+ // `CSSStyleDeclaration`.
12
+ //
13
+ // Callers, however, pass `style` as a React-Native `StyleProp` — most often an
14
+ // ARRAY (`style={[base, cond && override]}`, possibly nested, with falsy holes
15
+ // from `cond && {...}`). Spreading a raw array into an object literal produces
16
+ // numeric keys ("0", "1", …), which React then assigns onto the DOM node's
17
+ // `CSSStyleDeclaration`, throwing:
18
+ // `Failed to set an indexed property [0] on 'CSSStyleDeclaration'`.
19
+ //
20
+ // Why NOT `StyleSheet.flatten` (the convention the RNW-component forks such as
21
+ // `list/index.web.tsx` use): these raw-DOM forks have NO other react-native
22
+ // runtime dependency. Reaching for `StyleSheet.flatten` would couple them to
23
+ // the `react-native` → `react-native-web` bundler alias they otherwise never
24
+ // need — a coupling the jsdom test environment does not provide (its
25
+ // `react-native` mock stubs `flatten` as an identity no-op, so an array would
26
+ // pass through unflattened and the raw-DOM render would crash under test). A
27
+ // dependency-free flatten is provably correct in every bundling/test context,
28
+ // so it is the right convention for THIS sub-class of forks specifically.
29
+ //
30
+ // `StyleNode` is a readonly-closed recursion type: RN's own `StyleProp` array
31
+ // members are `readonly` and its `RegisteredStyle` members are branded numbers,
32
+ // neither of which is assignable back to `StyleProp` for direct recursion.
33
+ // `StyleProp<ViewStyle>` / `StyleProp<TextStyle>` ARE assignable to it, so call
34
+ // sites pass `style` / `labelStyle` with no cast. Later entries win, mirroring
35
+ // React-Native array-style precedence; falsy holes and unresolved registered-
36
+ // style ids (bare numbers — not resolvable without the RN runtime, and never
37
+ // passed to a raw-DOM fork in practice) are skipped.
38
+ // ---------------------------------------------------------------------------
39
+
40
+ type StyleNode =
41
+ | ViewStyle
42
+ | TextStyle
43
+ | number
44
+ | false
45
+ | null
46
+ | undefined
47
+ | ''
48
+ | ReadonlyArray<StyleNode>;
49
+
50
+ export function flattenWebStyle(style: StyleNode): CSSProperties {
51
+ if (Array.isArray(style)) {
52
+ return style.reduce<CSSProperties>(
53
+ (merged, entry) => Object.assign(merged, flattenWebStyle(entry)),
54
+ {},
55
+ );
56
+ }
57
+ return style && typeof style === 'object' ? (style as CSSProperties) : {};
58
+ }