@oxyhq/bloom 0.35.6 → 0.35.8

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.
@@ -23,16 +23,33 @@
23
23
  * content rather than displacing it). They render BEFORE the content wrapper so
24
24
  * z-index — not DOM order — decides layering.
25
25
  *
26
- * When `framed` is false (narrow screens) the panel is full-bleed: a single
27
- * flat surface, no rounding, no overlays.
26
+ * Framing is tri-state via the `framed` prop:
27
+ *
28
+ * - `undefined` (DEFAULT) → RESPONSIVE, driven purely by NativeWind: full-bleed
29
+ * below the `framedFrom` breakpoint, framed at/above it. The breakpoint is
30
+ * configurable per consumer (`framedFrom`, default `768` / Tailwind `md`); the
31
+ * rounding is gated by that breakpoint's `min-*:` variant and both overlays
32
+ * are always rendered but carry the matching `max-*:hidden` literal
33
+ * (`display:none` below the breakpoint), so the breakpoint is decided entirely
34
+ * in CSS — no consumer JS breakpoint hook.
35
+ * - `false` → NEVER framed (full-bleed at every size): a single flat surface,
36
+ * no rounding, no overlays.
37
+ * - `true` → ALWAYS framed (rounded + overlays at every size).
38
+ *
39
+ * The overlays are gated by VISIBILITY (a `max-*:hidden` literal for the chosen
40
+ * breakpoint) rather than by conditional mounting for the responsive case, so
41
+ * crossing the breakpoint never remounts them. The content wrapper likewise
42
+ * keeps a stable `key` so `{children}` reconciles in place across the breakpoint
43
+ * instead of remounting (which would reset feed scroll / the virtualizer +
44
+ * refetch).
28
45
  *
29
46
  * Native bundlers use `./index.tsx` (a plain rounded surface); web bundlers
30
47
  * select this file via the `"browser"` export condition in `package.json`.
31
48
  *
32
49
  * Styling is NativeWind-className-first; the literal class strings below MUST
33
50
  * stay literal so a consumer's Tailwind content-scan over `lib/**` picks them up
34
- * (no dynamic concatenation of the arbitrary `web:[…]` / `rounded-radius-28`
35
- * parts).
51
+ * (no dynamic concatenation of the arbitrary `web:[…]` / `rounded-radius-28` /
52
+ * `md:` parts — whole class strings are selected per mode instead).
36
53
  */
37
54
  import React, { memo } from 'react';
38
55
  import { View, type StyleProp, type ViewStyle } from 'react-native';
@@ -50,10 +67,37 @@ export const PANEL_TOP_INSET = 8;
50
67
  /** Bottom sticky inset (px) of the framed panel chrome. */
51
68
  export const PANEL_BOTTOM_INSET = 8;
52
69
 
70
+ /**
71
+ * Viewport width (px) at which a RESPONSIVE `ContentPanel` (`framed` undefined)
72
+ * switches from full-bleed to framed. Each value maps to a PRE-SHIPPED literal
73
+ * Tailwind class bundle — `768` (default) uses the named `md:`/`max-md:` screens
74
+ * (byte-identical to prior behavior), `640` uses `sm:`/`max-sm:`, `1024` uses
75
+ * `lg:`/`max-lg:`, and `500` uses the arbitrary `min-[500px]:`/`max-[500px]:`
76
+ * variants (there is no named screen at 500px). The breakpoint tokens are never
77
+ * built at runtime, so a consumer's Tailwind content-scan over `lib/**` resolves
78
+ * them verbatim.
79
+ */
80
+ export type ContentPanelFramedBreakpoint = 500 | 640 | 768 | 1024;
81
+
53
82
  export interface ContentPanelProps {
54
83
  children: React.ReactNode;
55
- /** Framed (wide screens) vs full-bleed (narrow screens). */
56
- framed: boolean;
84
+ /**
85
+ * Framing mode. Tri-state, resolved purely with NativeWind — no consumer
86
+ * breakpoint hook needed:
87
+ * - `undefined` (DEFAULT) → responsive: full-bleed below the `framedFrom`
88
+ * breakpoint, framed at/above it (rounded corners + sticky bleed-mask/border
89
+ * overlays).
90
+ * - `false` → never framed (full-bleed at every size).
91
+ * - `true` → always framed (rounded + overlays at every size).
92
+ */
93
+ framed?: boolean;
94
+ /**
95
+ * Viewport width at which the RESPONSIVE panel switches from full-bleed to
96
+ * framed. Only meaningful when `framed` is `undefined` (responsive) — ignored
97
+ * when `framed` is `true` (always framed) or `false` (never framed). Defaults
98
+ * to `768` (Tailwind `md`), reproducing the prior fixed behavior exactly.
99
+ */
100
+ framedFrom?: ContentPanelFramedBreakpoint;
57
101
  /** Override the surface background utility (defaults to `bg-card`). */
58
102
  surfaceClassName?: string;
59
103
  surfaceStyle?: StyleProp<ViewStyle>;
@@ -73,9 +117,50 @@ export interface ContentPanelProps {
73
117
  maskColor?: string;
74
118
  }
75
119
 
120
+ /**
121
+ * Per-breakpoint literal Tailwind variant tokens for the WEB responsive mode.
122
+ * Each field is a WHOLE literal class string (never assembled from parts at
123
+ * runtime) so the consumer's content-scan over `lib/**` picks up every
124
+ * arbitrary/`min-*`/`max-*`/`web:` token verbatim. Selecting a bundle by
125
+ * `framedFrom` value is the only runtime decision:
126
+ * - `surface` → framing rounding on the surface at/above the breakpoint.
127
+ * - `content` → the same rounding + a web-only horizontal clip on the
128
+ * inner content wrapper (`web:<bp>:overflow-x-clip`, platform-first-then-
129
+ * breakpoint — matching the consumer convention `web:sm:flex` in `@mercaria/ui`).
130
+ * - `overlayHidden` → `display:none` for the sticky overlays BELOW the breakpoint.
131
+ * `min-[500px]:` (≥500) and `max-[500px]:` (<500) are exactly complementary
132
+ * (meet at 500, no overlap/gap), mirroring the named `md:`/`max-md:` pair.
133
+ */
134
+ const RESPONSIVE_WEB: Record<
135
+ ContentPanelFramedBreakpoint,
136
+ { surface: string; content: string; overlayHidden: string }
137
+ > = {
138
+ 500: {
139
+ surface: 'min-[500px]:rounded-radius-28',
140
+ content: 'min-[500px]:rounded-radius-28 web:min-[500px]:overflow-x-clip',
141
+ overlayHidden: 'max-[500px]:hidden',
142
+ },
143
+ 640: {
144
+ surface: 'sm:rounded-radius-28',
145
+ content: 'sm:rounded-radius-28 web:sm:overflow-x-clip',
146
+ overlayHidden: 'max-sm:hidden',
147
+ },
148
+ 768: {
149
+ surface: 'md:rounded-radius-28',
150
+ content: 'md:rounded-radius-28 web:md:overflow-x-clip',
151
+ overlayHidden: 'max-md:hidden',
152
+ },
153
+ 1024: {
154
+ surface: 'lg:rounded-radius-28',
155
+ content: 'lg:rounded-radius-28 web:lg:overflow-x-clip',
156
+ overlayHidden: 'max-lg:hidden',
157
+ },
158
+ };
159
+
76
160
  const ContentPanelComponent: React.FC<ContentPanelProps> = ({
77
161
  children,
78
162
  framed,
163
+ framedFrom = 768,
79
164
  surfaceClassName,
80
165
  surfaceStyle,
81
166
  contentClassName,
@@ -83,17 +168,35 @@ const ContentPanelComponent: React.FC<ContentPanelProps> = ({
83
168
  showStickyFrame,
84
169
  maskColor,
85
170
  }) => {
86
- // Dev-only invariant — must run unconditionally before the early `!framed`
87
- // return so the hook order stays stable across renders (rules of hooks).
171
+ // Dev-only invariant — must run unconditionally (before deriving any
172
+ // mode-specific branch) so the hook order stays stable (rules of hooks).
88
173
  useContentPanelNestingGuard();
89
174
  const { colors } = useTheme();
90
175
 
91
- const surfaceClass = framed
92
- ? ['flex-1 rounded-radius-28', surfaceClassName ?? 'bg-card'].join(' ')
93
- : ['flex-1', surfaceClassName ?? 'bg-card'].join(' ');
94
- const contentClass = framed
95
- ? ['flex-1 rounded-radius-28 web:overflow-x-clip', contentClassName].filter(Boolean).join(' ')
96
- : ['flex-1', contentClassName].filter(Boolean).join(' ');
176
+ // Tri-state: `undefined` → responsive (md:-gated), `true` → always framed,
177
+ // `false` never framed (full-bleed).
178
+ const responsive = framed === undefined;
179
+ const showOverlays = framed !== false;
180
+ // Responsive mode selects a pre-shipped literal breakpoint bundle by value;
181
+ // `framed === true`/`false` ignore `framedFrom` (fixed always/never framing).
182
+ const bp = RESPONSIVE_WEB[framedFrom];
183
+
184
+ // Whole literal class strings selected per mode (the Tailwind content-scan
185
+ // over `lib/**` requires each arbitrary/`min-*`/`max-*`/`web:` token stay
186
+ // literal — the breakpoint tokens live whole in `RESPONSIVE_WEB` above and are
187
+ // only spliced in, never assembled from parts).
188
+ const surfaceBase = responsive
189
+ ? `flex-1 ${bp.surface}`
190
+ : framed
191
+ ? 'flex-1 rounded-radius-28'
192
+ : 'flex-1';
193
+ const contentBase = responsive
194
+ ? `flex-1 ${bp.content}`
195
+ : framed
196
+ ? 'flex-1 rounded-radius-28 web:overflow-x-clip'
197
+ : 'flex-1';
198
+ const surfaceClass = [surfaceBase, surfaceClassName ?? 'bg-card'].join(' ');
199
+ const contentClass = [contentBase, contentClassName].filter(Boolean).join(' ');
97
200
 
98
201
  return (
99
202
  <ContentPanelNestingContext.Provider value={true}>
@@ -101,26 +204,31 @@ const ContentPanelComponent: React.FC<ContentPanelProps> = ({
101
204
  {...({ className: surfaceClass } as Record<string, string>)}
102
205
  style={surfaceStyle}
103
206
  >
104
- {/* (1) Bleed-mask overlay — gutter box-shadow ring, below chrome. Framed only. */}
105
- {framed && (
207
+ {/* (1) Bleed-mask overlay — gutter box-shadow ring, below chrome. Not
208
+ rendered when never-framed; `max-md:hidden` (display:none <md) when
209
+ responsive, so the breakpoint is decided in CSS, not by remounting. */}
210
+ {showOverlays && (
106
211
  <View
107
212
  key="bleed-mask"
108
213
  pointerEvents="none"
109
214
  {...({
110
- className:
111
- 'web:sticky web:top-2 z-30 h-[calc(100dvh-16px)] w-full rounded-radius-28 web:[margin-bottom:calc(-100dvh+16px)] web:[clip-path:inset(-12px)]',
215
+ className: responsive
216
+ ? `web:sticky web:top-2 z-30 h-[calc(100dvh-16px)] w-full rounded-radius-28 ${bp.overlayHidden} web:[margin-bottom:calc(-100dvh+16px)] web:[clip-path:inset(-12px)]`
217
+ : 'web:sticky web:top-2 z-30 h-[calc(100dvh-16px)] w-full rounded-radius-28 web:[margin-bottom:calc(-100dvh+16px)] web:[clip-path:inset(-12px)]',
112
218
  } as Record<string, string>)}
113
219
  style={{ boxShadow: `0 0 0 ${GUTTER_MASK_SPREAD}px ${maskColor ?? colors.background}` }}
114
220
  />
115
221
  )}
116
- {/* (2) Border-frame overlay — one continuous rounded border, above all. Framed only. */}
117
- {framed && showStickyFrame !== false && (
222
+ {/* (2) Border-frame overlay — one continuous rounded border, above all.
223
+ Same visibility gating as the bleed-mask. */}
224
+ {showOverlays && showStickyFrame !== false && (
118
225
  <View
119
226
  key="border-frame"
120
227
  pointerEvents="none"
121
228
  {...({
122
- className:
123
- 'web:sticky web:top-2 z-[120] h-[calc(100dvh-16px)] w-full rounded-radius-28 border border-border web:[margin-bottom:calc(-100dvh+16px)]',
229
+ className: responsive
230
+ ? `web:sticky web:top-2 z-[120] h-[calc(100dvh-16px)] w-full rounded-radius-28 border border-border ${bp.overlayHidden} web:[margin-bottom:calc(-100dvh+16px)]`
231
+ : 'web:sticky web:top-2 z-[120] h-[calc(100dvh-16px)] w-full rounded-radius-28 border border-border web:[margin-bottom:calc(-100dvh+16px)]',
124
232
  } as Record<string, string>)}
125
233
  />
126
234
  )}